Compare commits

...
179 Commits
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 03c3dd72fe Highlights: auto-fill episode audio from the feed by season + episode number; v2.46.1
Publish Container / build (push) Successful in 15m38s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 09:09:23 -04:00
nmemmertandClaude Sonnet 5.5 30a7e8a6cb Study: lesson reminders on by default for new accounts; one-time backfill turns them on for existing accounts; v2.46.0
Publish Container / build (push) Successful in 16m7s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 16:53:03 -04:00
nmemmertandClaude Sonnet 5.5 a11adf8ad2 Episodes: Browse Episodes defaults to the current series' season; v2.45.3
Publish Container / build (push) Successful in 16m53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 11:37:35 -04:00
nmemmertandClaude Sonnet 5.5 e2cd66386b Episodes: Podcast Highlights always defaults to the current series tab; v2.45.2
Publish Container / build (push) Successful in 16m24s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 10:01:16 -04:00
nmemmertandClaude Sonnet 5.5 597f996474 Fix mobile nav: tapping Episodes/Studies opens submenu instead of navigating, larger caret, scrollable open menu; v2.45.1
Publish Container / build (push) Successful in 14m27s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:10:16 -04:00
nmemmertandClaude Sonnet 5.5 a1adfa0ef1 Scripture Index: verse of the week (admin override or weekly rotation through covered passages); v2.45.0
Publish Container / build (push) Successful in 17m33s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 11:50:19 -04:00
nmemmertandClaude Sonnet 5.5 e9d9dd0d50 Analytics: unique visitors this week + probed-URLs list; Scripture Index: most-studied strip + print; admin warning for unparseable passage references; v2.44.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 11:38:26 -04:00
nmemmertandClaude Sonnet 5.5 6a5d525d6e Fix Scripture Index doubled/unparseable refs, Titus passage fetch 400, Ko-fi QR 404, auto-load study comments; stop probe URLs inflating Real Hits, real 404 status for unknown URLs; v2.43.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 11:30:05 -04:00
nmemmertandClaude Sonnet 5.5 09177f47ed Notes: multiple notes per section with last-edited dates, delete, sort by recently edited; Word export now readable; shared note-format module + tests; v2.43.0
Publish Container / build (push) Successful in 16m3s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 08:14:45 -04:00
nmemmertandClaude Sonnet 5.5 401fd451de Fix lint errors, upgrade react-simple-maps to v5 (clears high vuln), My Notes: search other studies + Markdown download; v2.42.2
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 08:08:08 -04:00
nmemmertandClaude Sonnet 5.5 cf8835119e Update dependencies within semver ranges (react 19.3, tiptap 3.32, express 5.3, etc.); v2.42.1
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 08:03:03 -04:00
nmemmertandClaude Sonnet 5.5 9710834b16 Notes: raise limits (3,000 notes, 200k chars each), reject instead of truncating oversize notes, save-error feedback, richer editor toolbar + word count, copy note; v2.42.0
Publish Container / build (push) Successful in 19m58s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 07:57:26 -04:00
nmemmertandClaude Sonnet 5.5 ae0016cfee Gospel page audio: choose-your-path narration (case, then Guilty or Not guilty tracks), branching player, three admin audio fields, generator --sample/--only; v2.41.0
Publish Container / build (push) Successful in 16m4s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 08:24:33 -04:00
nmemmertandClaude Sonnet 5.5 ad1c3e35b1 Gospel page audio: narration script, ElevenLabs generator, player on /salvation, admin Gospel Page setting; v2.40.0
Publish Container / build (push) Successful in 17m6s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 08:06:32 -04:00
nmemmertandClaude Sonnet 5.5 330c354d54 Salvation page: big, glowing 'Enter Your Plea' call-to-action at the end of the record; v2.39.2
Publish Container / build (push) Successful in 16m14s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 16:22:49 -04:00
nmemmertandClaude Sonnet 5.5 b3e887aef2 Salvation page: redesigned notification email, visitor confirmation email, clear on-screen confirmation and email notice, phone fixes; v2.39.1
Publish Container / build (push) Successful in 15m35s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 16:12:07 -04:00
nmemmertandClaude Sonnet 5.5 27656b84f2 Gospel courtroom page: tabs, plea dropdown, plea email, Acts, accuser/charges, site nav and links; fix service worker for /salvation; courtroom contact type; accept community type; v2.39.0
Publish Container / build (push) Successful in 16m32s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 15:36:37 -04:00
nmemmertandClaude Sonnet 5.5 ea463b5851 Redo detail, study, utility and gate pages; fix admin live preview (CSP frame-src self) and preview every admin section; v2.38.0
Publish Container / build (push) Successful in 15m34s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 14:23:21 -04:00
nmemmertandClaude Sonnet 5.5 5d5c026762 Redo /newsletter and /nuggets pages; add Nuggets episode tags with admin editor; v2.37.0
Publish Container / build (push) Successful in 16m20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 13:49:15 -04:00
nmemmertandClaude Sonnet 5.5 ba35ccab19 Remove local PRISM.mp4; default PRISM video to the files host; v2.36.5
Publish Container / build (push) Successful in 16m4s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 12:01:14 -04:00
nmemmertandClaude Sonnet 5.5 a0a9775c8a PRISM video: remount player when URL changes so admin value replaces the default; v2.36.4
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 12:00:21 -04:00
nmemmertandClaude Sonnet 5.5 166ef2e2fb CSP: allow Ko-fi widget, Cloudflare Insights beacon and Truth for Life styles; v2.36.3
Publish Container / build (push) Successful in 15m28s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 11:39:40 -04:00
nmemmertandClaude Sonnet 5.5 88e9d0f7c5 Bump version to 2.36.2
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 11:36:51 -04:00
nmemmertandClaude Sonnet 5.5 059fc7f8c0 Service worker: stop CacheFirst-caching videos under /images/ so replaced videos play; v2.36.2
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 11:36:48 -04:00
nmemmertandClaude Sonnet 5.5 753b5c77cf Study guide button downloads the file directly with no name/email form; v2.36.1
Publish Container / build (push) Successful in 15m3s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 09:56:20 -04:00
nmemmertandClaude Sonnet 5.5 fed2b6bacc Studies: add a cover image setting, used as the backdrop of the featured study card on the homepage; v2.36.0
Publish Container / build (push) Successful in 16m2s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 09:14:59 -04:00
nmemmertandClaude Sonnet 5.5 82ce312898 Studies: add a Download Study Guide button to the study page, automatic from the linked series/book or chosen per study in admin; v2.35.0
Publish Container / build (push) Successful in 18m26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 08:16:44 -04:00
nmemmertandClaude Sonnet 5.5 b48a81a00c Home page: replace Where to Next card grid with a featured-study card plus an Explore list; v2.34.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 08:13:57 -04:00
nmemmertandClaude Sonnet 5.5 b04a8b30cd Stronger hover on gold buttons: brighter fill, lift and glow; v2.33.2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 08:10:17 -04:00
nmemmertandClaude Sonnet 5.5 f3fdd32943 Give non-gold buttons and pills a soft gold tint instead of an empty outline; v2.33.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 08:08:53 -04:00
nmemmertandClaude Sonnet 5.5 ee707fad10 Analytics consent banner redesign: compact corner card with title, privacy link and equal-weight Accept/Decline; v2.33.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 08:01:33 -04:00
nmemmertandClaude Sonnet 5.5 e82e8222f8 Home page: rename Downloads card and series button to Resources; v2.32.4
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 08:00:29 -04:00
nmemmertandClaude Sonnet 5.5 04a572bb6e Home page: center all text in the newsletter, share and Ko-fi cards; v2.32.3
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 07:59:24 -04:00
nmemmertandClaude Sonnet 5.5 448e3eec2b Home page: center the QR codes in the share and Ko-fi cards; v2.32.2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 07:58:36 -04:00
nmemmertandClaude Sonnet 5.5 95aecf739f Home page: add Ko-fi as a third card beside newsletter and share; v2.32.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 07:58:17 -04:00
nmemmertandClaude Sonnet 5.5 729cf94c12 Home page redesign: latest-episode player beside current series, ways-in cards, finished-books shelf, shortened PRISM, newsletter + share band, quieter 'more from us' group; v2.32.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 07:56:11 -04:00
nmemmertandClaude Sonnet 5.5 d1c84aec6b Episodes page: show both series lanes (A and B) in the hero with lane-aware series chips; v2.31.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 07:52:22 -04:00
nmemmertandClaude Sonnet 5.5 f1577e9d8a Episodes + Finished Books redesign: current-series hero, searchable episode browser with series chips, shared episode rows; finished-books library with search, testament/sort filters, featured card, episode counts and badges; v2.31.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 07:47:56 -04:00
nmemmertandClaude Sonnet 5.5 03f46ae683 Scripture Index redesign: search, testament/source filters, book chips, collapsible books, on-demand verse text; v2.30.0
Publish Container / build (push) Successful in 15m16s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 17:07:28 -04:00
nmemmertandClaude Sonnet 5.5 9f52437a07 Ask for name and email before sending visitors to Amazon or Apple Books (switchable in admin → Books); v2.29.0
Publish Container / build (push) Successful in 15m46s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 16:51:51 -04:00
nmemmertandClaude Sonnet 5.5 2c342f14bd About page redesign: photo + bio intro with actions, compact about-the-show card, where-to-start links, testimonials before the support card; v2.28.0
Publish Container / build (push) Successful in 15m26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 16:25:03 -04:00
nmemmertandClaude Sonnet 5.5 3cf7e02561 Contact page redesign: message-type chips with hints, side-by-side names, character count, sticky profile card, form first on phones, compact Ko-fi card; v2.27.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 16:22:27 -04:00
nmemmertandClaude Sonnet 5.5 d06fcba60b Resources page redesign: tabs + compact grids; download page redesign with site header, two-column card and a mobile-safe form; v2.26.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 16:19:28 -04:00
nmemmertandClaude Sonnet 5.5 9268514219 Search bar pinned again, now flush under the header at every width (sticky offset follows the header's real height); v2.25.2
Publish Container / build (push) Successful in 15m47s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 16:10:31 -04:00
nmemmertandClaude Sonnet 5.5 5801af9dfb Search bar scrolls away with the page instead of staying pinned under the header; v2.25.1
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 16:07:04 -04:00
nmemmertandClaude Sonnet 5.5 d0741d628e Admin shortcut in the account menu, switched on per study user from /admin; v2.25.0
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 16:03:41 -04:00
nmemmertandClaude Sonnet 5.5 853e758bab Desktop type scale: page titles 48px, section headings 32px, hero 64px, About copy 17px; keep certificate title out of the scale; v2.24.0
Publish Container / build (push) Successful in 16m14s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 15:05:42 -04:00
nmemmertandClaude Sonnet 5.5 99d8b23567 Mobile: one type scale across public pages (h1 32px, hero 40px, h2 24px, nothing under 12px, About copy 17px); v2.23.3
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:46:39 -04:00
nmemmertandClaude Sonnet 5.5 5fb44f0490 Header: Follow on Spotify button gets the Spotify icon, solid gold style, hover/focus states and a bigger mobile tap target; v2.23.2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:41:16 -04:00
nmemmertandClaude Sonnet 5.5 9e01c04249 Fix sideways scroll on phones: clip the hero glow on narrow screens; v2.23.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:39:18 -04:00
nmemmertandClaude Sonnet 5.5 54575c22ef Header: replace My Account nav link with an account icon in the top right (sign-in link / account menu), collapse header below 900px; v2.23.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:36:22 -04:00
nmemmertandClaude Sonnet 5.5 4a7c3cc967 Q&A: pinned questions are featured on the home page, home cards link to the full answer, newest answer first; v2.22.1
Publish Container / build (push) Successful in 16m39s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:30:04 -04:00
nmemmertandClaude Sonnet 5.5 960ee41375 Rework Q&A: admin inbox with one-step publish, topics and scripture references; simpler public page with ask-a-question; stop exposing asker emails; v2.22.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:27:17 -04:00
nmemmertandClaude Sonnet 5.5 e6bef529e6 Dashboard: Nuggets attention line counts uploads still open in the workflow and clears when finished; v2.21.0
Publish Container / build (push) Successful in 16m6s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 12:02:30 -04:00
nmemmertandClaude Sonnet 5.5 35d3c9ca33 Season Check: series card, study guide, next-season handover and unseasoned-script checks; v2.20.1
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 11:56:42 -04:00
nmemmertandClaude Sonnet 5.5 50b0e83c2b Add Season Check page and dashboard card: per-lane readiness (season, study, lessons, highlights, feed); v2.20.0
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 11:54:00 -04:00
nmemmertandClaude Sonnet 5.5 77bce2ae0c Hold episode highlights until the episode is in the Spotify feed; auto-fill study lesson audio from the feed by season + episode; v2.19.0
Publish Container / build (push) Successful in 16m16s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 11:47:22 -04:00
nmemmertandClaude Sonnet 5.5 69d445013a Podcast workflow: Finished seasons section grouping fully released seasons; v2.18.0
Publish Container / build (push) Successful in 16m19s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 10:34:44 -04:00
nmemmertandClaude Sonnet 5.5 1bf11a3669 Resolve the Seafile host over IPv4 only (musl/Alpine ENOTFOUND on IPv6 NXDOMAIN); v2.17.3
Publish Container / build (push) Successful in 14m53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 10:01:13 -04:00
nmemmertandClaude Sonnet 5.5 22c0038ebb Nuggets workflow audio: include failure reason in the error message; v2.17.2
Publish Container / build (push) Successful in 15m27s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 09:38:17 -04:00
nmemmertandClaude Sonnet 5.5 11f3d76e9b Fix Nuggets workflow audio: serve with audio content type (nosniff blocked octet-stream), show playback errors; v2.17.1
Publish Container / build (push) Successful in 16m34s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:56:41 -04:00
nmemmertandClaude Sonnet 5.5 326838e5bc Podcast workflow: episodes start manually via Start new episode (name, season, ep #); scripts no longer create entries; v2.17.0
Publish Container / build (push) Successful in 16m42s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:33:23 -04:00
nmemmertandClaude Sonnet 5.5 3a69e03e60 Podcast workflow: per-lane season number set on the page, only that season shown; v2.16.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:28:04 -04:00
nmemmertandClaude Sonnet 5.5 900a3efee3 Podcast workflow: assign lanes by hand, match by season, unique keys for unseasoned episodes; drop old checklist data file; v2.16.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:25:00 -04:00
nmemmertandClaude Sonnet 5.5 ac1b06f945 Fix admin dashboard crash after removing upcoming events (drop Upcoming card); v2.15.2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:21:37 -04:00
nmemmertandClaude Sonnet 5.5 28fc822132 Podcast workflow steps: read script, record, edit, upload; v2.15.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:18:51 -04:00
nmemmertandClaude Sonnet 5.5 f181cdb182 Add Podcast Episode Workflow (Lane A / Lane B) with Nuggets steps; released dates from the feed; v2.15.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:16:32 -04:00
nmemmertandClaude Sonnet 5.5 5c2f9113b7 Remove unused calendar styles; v2.14.4
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:12:22 -04:00
nmemmertandClaude Sonnet 5.5 13d0eb6651 Remove podcast checklist backend, calendar events and reminder emails; v2.14.3
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:11:09 -04:00
nmemmertandClaude Sonnet 5.5 99b19faede Remove Lane A/B production checklist panel from admin; v2.14.2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:09:28 -04:00
nmemmertandClaude Sonnet 5.5 66b7bbef52 Remove standalone podcast-checklist app; admin checklist now uses the existing checklist backend; v2.14.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:06:32 -04:00
nmemmertandClaude Sonnet 5.5 d6d2c087b2 Add Podcast Production Checklist (Lane A / Lane B) to admin; v2.14.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:04:10 -04:00
nmemmertandClaude Sonnet 5.5 ffb3551b7e Add Nuggets Episode Workflow to admin (listen, cold open, record, edit, Spotify); v2.13.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 07:59:04 -04:00
nmemmertandClaude Sonnet 5.5 eff119e7f0 Redesign study Notes, Reflection questions and Community pages; v2.12.0
Publish Container / build (push) Successful in 15m20s
- Notes: two-pane reader with searchable lesson list and snippets, track chips, friendlier empty states
- Reflection questions: numbered question cards, answered progress bar, unsaved-changes status, sticky save bar, share-to-discussion only when discussion is open
- Community: compact header, lesson browser sidebar with post counts (select on mobile), guidelines card, card-style posts

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 12:00:09 -04:00
nmemmertandClaude Sonnet 5.5 0ab94bfcd4 Discussion stays open for finished studies; only planned studies are closed; v2.11.2
Publish Container / build (push) Successful in 16m10s
A finished study is just no longer the main study, so students who take it later still need discussion.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 11:52:56 -04:00
nmemmertandClaude Sonnet 5.5 f1d4909acb Discussion only open while a study is active; v2.11.1
Publish Container / build (push) Has been cancelled
- Community posts, replies, likes and lesson comments are refused (403) for finished and planned studies
- Reading a closed study's community/comments returns an empty list with closed:true
- Discussion hub lists only active studies; community page shows a closed notice
- Lesson page hides Lesson Discussion, 'Discuss this lesson' and 'Go to Community' for inactive studies; Community buttons hidden on study/dashboard pages
- Tests for isStudyDiscussionOpen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 11:49:03 -04:00
nmemmertandClaude Sonnet 5.5 bb83f3e8c5 Completed studies: finishing a study keeps access after leaving; certificates always shown; no reminders for finished studies; v2.11.0
Publish Container / build (push) Successful in 15m31s
- Completion is stored on the user (completedStudySlugs, sticky) when every lesson is done; backfilled at startup
- Lessons, notes, progress, quiz, community and certificate stay open for completed studies after leaving the enrollment
- Dashboard/My Account list completed studies with Review Lessons and certificate access
- Study page shows a completed banner
- Lesson-release reminders, weekly digest and re-engagement emails skip finished studies
- Fix: streak fields (current/longest/last studied) and firstCompletionAt were dropped on every restart when users were loaded
- Tests for completion/access/re-engagement

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 11:42:58 -04:00
nmemmertandClaude Sonnet 5.5 7301982bb1 Fix certificate View link (wrong /study/certificate URL), certificates in My Account, name on certificate; v2.10.10
Publish Container / build (push) Successful in 15m29s
- Certificate URLs in the account overview and the issuance email pointed at /study/certificate/:token,
  which the router treated as a lesson page; now /certificate/:token (old links redirect)
- My Account Certificates card lists every enrolled study: progress, Generate, View, Copy link, Download PNG, Share
- Certificate canvas used a non-existent /api/study-account route and always printed 'Student'; now reads the account name
- Panel no longer collapses after generating

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 11:13:44 -04:00
nmemmertandClaude Sonnet 5.5 899e715067 Redesign My Account page: profile header, stats, progress cards, settings/security menus, danger zone; v2.10.9
Publish Container / build (push) Successful in 16m15s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 10:45:37 -04:00
nmemmertandClaude Sonnet 5.5 669cd6555c Redesign study landing page: track cards, numbered steps, tidier hero and Q&A callout, themed progress bars; v2.10.8
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 10:41:18 -04:00
nmemmertandClaude Sonnet 5.5 52568db82b Sync package-lock version with package.json (2.10.7)
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 10:37:53 -04:00
nmemmertandClaude Sonnet 5.5 b8410da5f4 Study pages: progress bar, collapsible chapters, compact upcoming lessons, class-based badges, cleaner sign-in/enroll gates; v2.10.7
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 10:37:44 -04:00
nmemmertandClaude Sonnet 5.5 640d0c1c18 Redesign sign-in / create-account page: split layout, benefits panel, show/hide password, real form submit; v2.10.6
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 10:33:26 -04:00
nmemmertandClaude Sonnet 5.5 b708371cc5 Finished book page: move QR code up to the top right of the hero; v2.10.5
Publish Container / build (push) Successful in 15m22s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 10:18:27 -04:00
nmemmertandClaude Sonnet 5.5 fcc6604926 Finished book QR label: say it downloads the full album; v2.10.4
Publish Container / build (push) Successful in 15m11s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 09:50:01 -04:00
nmemmertandClaude Sonnet 5.5 4d37fac641 Finished Books detail: one tabbed Episodes section (Highlights / All Episodes), searchable playlist, on-demand player; v2.10.3
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 09:47:23 -04:00
nmemmertandClaude Sonnet 5.5 b96e9a79dc Redesign Start Here page: numbered step cards, icons, connectors, mobile stack; v2.10.2
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 09:25:54 -04:00
nmemmertandClaude Sonnet 5.5 2a64f29a00 Second hardening pass: atomic data writes, bible-passage limits, webhook SSRF guard, note sanitizing, CI gates; v2.10.1
Publish Container / build (push) Successful in 15m46s
- Atomic temp-file + rename for all JSON data writes (no truncated files on crash)
- /api/bible-passage: rate limit, range caps (60 verses), bounded cache
- Admin webhooks: https-only, block private/internal/Tailscale addresses (checked at registration and delivery), no redirects
- Resend webhook token compare is timing-safe
- DOMPurify on rendered study notes and broadcast preview
- CI runs lint + tests before build
- Permissions-Policy and HSTS (production) headers; JSON error handler for /api
- Tests for URL safety and atomic writes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 09:00:28 -04:00
nmemmertandClaude Sonnet 5.5 8a796964b9 Security & hardening pass: scrypt password hashing, persisted admin sessions, upvote/analytics rate limits, dependency fixes, healthcheck, lint cleanup; v2.10.0
Publish Container / build (push) Successful in 17m3s
- Study passwords: salted scrypt; legacy SHA-256 hashes verified and upgraded on login
- Admin sessions persisted (hashed tokens) so deploys no longer log the admin out
- Upvotes: rate limit + one per client per question, approved questions only; rate limit on analytics play/event
- npm audit: 46 -> 4 vulnerabilities (tiptap bumped to 3.31.4)
- Untrack per-user data (study users/notes/progress/etc.) from git
- Dockerfile HEALTHCHECK; node --test suite for password hashing
- Fix email template cards remounting (lost input focus) on each keystroke
- Lint: 34 errors -> 0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 08:45:39 -04:00
nmemmertandClaude Sonnet 5.5 e2150e1cd7 One companion study guide per series; v2.9.8
Publish Container / build (push) Successful in 21m12s
- Second series gets its own guide (Resources page card, download page, admin fields)
- When a series ends (scheduled or manual) its guide moves onto its Finished Book, which shows a guide button; the main series' guide is cleared unless the launch supplies a new one
- Lane B launch schedule can supply the incoming series' guide
- Download lookup covers second-series and finished-book guides; guide URLs are sanitized on save and on use

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 16:00:52 -04:00
nmemmertandClaude Sonnet 5.5 39691aedb3 Admin: show and manage highlights archived on Finished Books (restore/remove); v2.9.7
Publish Container / build (push) Successful in 15m33s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 15:43:04 -04:00
nmemmertandClaude Sonnet 5.5 752611d121 Episode scripts: file by season + episode so numbers can repeat per book; search labels for current series; v2.9.6
Publish Container / build (push) Successful in 16m51s
- Scripts keyed "<season>-<episode>" (legacy ones migrate on first start; unseasoned ones keep their plain key)
- Season backfill handles episode numbers shared across seasons (title match fallback)
- Fix: "Backfill seasons from RSS" was swallowed by the upload route
- Global search labels script results with the title of the series playing now (both lanes), not just finished books

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 15:31:11 -04:00
nmemmertandClaude Sonnet 5.5 4ddedf0f81 Revert accidental runtime data change to study-reminders.json; v2.9.5
Publish Container / build (push) Successful in 15m50s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 11:26:47 -04:00
nmemmertandClaude Sonnet 5.5 3bd467e198 Homepage: show two current series side by side as compact cards; v2.9.4
Publish Container / build (push) Successful in 15m53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 11:26:41 -04:00
nmemmertandClaude Sonnet 5.5 810097bb8e Episodes page: highlights as one tab per series with show-all preview; v2.9.3
Publish Container / build (push) Successful in 14m56s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 11:24:19 -04:00
nmemmertandClaude Sonnet 5.5 1eb0dc1296 Admin: split long pages into tabs instead of scrolling; v2.9.2
Publish Container / build (push) Successful in 16m23s
- Shared tab bar; chosen tab is remembered per page
- Current Series: Main Series / Second Series / Launch: Main / Launch: Second
- Resources, Footer & Global, SEO, Legal, Start Here, Custom Links: former "Jump to" scroll links are now tabs
- Dashboard: Overview / Recent Activity / Study Users
- Studies editor: Lessons / Study Settings

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 10:49:41 -04:00
nmemmertandClaude Sonnet 5.5 652b003bd9 Episode highlights per series: tag by lane, group on Episodes page, move to Finished Book when series ends; v2.9.1
Publish Container / build (push) Successful in 15m21s
- Highlights get a Series field (Lane A / Lane B / General); legacy highlights count as Lane A
- Episodes page groups highlights under each series' title
- Ending a series (scheduled or manual) moves only that series' highlights onto its Finished Book page; other lane and General stay
- Archived highlights keep working: detail pages, sitemap, scripture index and search include them

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 10:46:33 -04:00
nmemmertandClaude Sonnet 5.5 ddcaec0ddf Two concurrent series lanes, Complete studies, self-paced finished books; v2.9.0
Publish Container / build (push) Successful in 17m50s
- Study status 'complete' (manual or auto when all lessons released); stays enrollable and self-paced (no sequential lock, Resume button, Finished Studies hub section)
- Studies get lane (A/B) and expected start date; planned studies show "Starts <date>"; hub shows next lesson date instead of hardcoded pace
- Second concurrent series (homepage card, admin panel) with its own scheduled launch; transitions are per lane and flip the ending study to Complete and the incoming study to Active
- Finished books link to their study: "Study This Book" on playlist page, audio-only link from hub (playlist stays public, no account)
- Admin: bulk release scheduler (every N days), same-day collision warning, linked-study selects
- Weekly digest no longer dumps a finished study's backlog on late enrollees
- CI: build and tag the container as :beta on pushes to the beta branch

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 10:14:02 -04:00
nmemmertandClaude Sonnet 5.5 2d8e4be5ea Study hub: show available vs coming-soon study counts instead of total tracks; v2.8.5
Publish Container / build (push) Successful in 14m52s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:51:48 -04:00
nmemmertandClaude Sonnet 5.5 cd40ecc207 Add Active/Planned status badges, dim planned studies, sort active first in track lists; v2.8.4
Publish Container / build (push) Successful in 16m49s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:57:53 -04:00
nmemmertandClaude Sonnet 5.5 db1737baf3 Hide Enroll for planned studies (Coming soon) and reject planned enrollments server-side; v2.8.3
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:56:17 -04:00
nmemmertandClaude Sonnet 5.5 b1695b2b59 Add release date and episode # columns to admin lesson list with inline date editing; use local time for release dates; v2.8.2
Publish Container / build (push) Successful in 18m28s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:46:25 -04:00
nmemmertandClaude Sonnet 5.5 0ff0b32b97 Fix scheduled series launch: read schedule from draft, apply to draft+published, check every minute, local-time picker; v2.8.1
Publish Container / build (push) Successful in 16m22s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 10:12:05 -04:00
nmemmertandClaude Sonnet 5.5 134a2c403e Expand admin dashboard with trends, health, study and email insights; v2.8.0
Publish Container / build (push) Successful in 15m53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:19:25 -04:00
nmemmertandClaude Sonnet 5.5 dad0e5691e Retry Resend calls on 429 and throttle Newsletter Archive lookups; v2.7.1
Publish Container / build (push) Successful in 15m57s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 09:14:49 -04:00
nmemmertandClaude Sonnet 5.5 66cdb9c4c9 Send app broadcasts as Resend Broadcasts; add selective import from Resend; v2.7.0
Publish Container / build (push) Successful in 15m22s
- Broadcast Email creates and sends a Resend Broadcast to the Resend segment (unsubscribe handling, reports, New Episode topic, scheduling, test send)
- Subscribers: import chosen Resend-only contacts into the app list

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 08:54:23 -04:00
nmemmertandClaude Sonnet 5.5 af967d0049 Add Compare with Resend panel to Subscribers; stop resubscribing opted-out contacts; v2.6.1
Publish Container / build (push) Successful in 16m27s
- Subscribers: compare in-app list with Resend audience/segment, with one-click fixes
- syncContactToResend no longer resets unsubscribed on existing contacts

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 08:29:56 -04:00
nmemmertandClaude Sonnet 5.5 e74bd1df5d Upgrade Asset Manager and Inbox; load Resend templates in Broadcast; v2.5.0
Publish Container / build (push) Successful in 16m39s
- Asset Manager: search/filter/sort, list+grid views, rename, move between R2 folders, tag chips, bulk actions, used-in counts; fix R2 delete lookup
- Inbox: readable wrapped rows with subject/preview, real read state, type filters, bulk actions, mojibake repair
- Broadcast: pick a Resend template to fill subject and body

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 07:45:50 -04:00
nmemmertandClaude Sonnet 5.5 4931bfab4d Add podcast image picker to the Email Templates welcome card; v2.3.2
Publish Container / build (push) Failing after 27s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 07:57:33 -04:00
nmemmertandClaude Sonnet 5 abfbb1981b Fix newsletter issue pages rendering left-aligned on white; v2.3.1
Publish Container / build (push) Successful in 17m23s
The Resend email HTML uses align="left" on its outer wrapper table and a
white background, both fine inside a narrow inbox pane but broken in a
full-width browser tab: the content stuck to the left edge with a large
white field to its right. Override with CSS that centers the content and
matches the site's dark background.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 12:50:47 -04:00
nmemmertandClaude Sonnet 5 fe4c09f8a3 Add a newsletter archive so past issues can be shared and found; v2.3.0
Publish Container / build (push) Successful in 20m51s
Web-hosted copies of episode newsletters, published from Resend broadcasts
tagged with the "New Episode" topic. Gives Facebook (and anyone else) a
proper link to share instead of raw HTML email, and makes issues findable
via site search and Google.

- Admin "Newsletter Archive" tab lists sent Resend broadcasts, flags ones
  tagged "New Episode", and publishes them with one click (pulls the HTML
  straight from Resend's API)
- Public /newsletter archive index and /newsletter/:slug issue pages,
  server-rendered with per-issue Open Graph tags for social previews
- Unsubscribe merge-tag stripped from the web copy; archive + subscribe
  links appended
- Issues added to sitemap.xml and the on-site search index

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 12:17:13 -04:00
nmemmertandClaude Sonnet 5 60b23b0fba Remove stale agent worktree and ignore .claude/worktrees going forward
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 12:17:03 -04:00
nmemmertandClaude Sonnet 5 d1a68754a1 Fix analytics data-loss bugs and clean up the admin dashboard; v2.2.4
Publish Container / build (push) Successful in 16m3s
- Self-heal consent desync: if the server's consent cookie and the
  client's localStorage flag fall out of sync, the client now notices
  and stops silently sending events into a void.
- Stop double-counting hard page loads in hit/visitor stats.
- Flush pending disk writes on SIGTERM/SIGINT so redeploys don't drop
  the last few events.
- Log rate-limiter drops once per window (diagnoses proxy misconfig).
- Widen the bot-detection allowlist to avoid flagging real browsers
  (Chrome-iOS, Samsung Browser, etc.) as bots.
- Stop merging distinct visitors who share an IP when a real session
  cookie is already present — only fall back to IP correlation when no
  cookie was sent at all.
- Include analyticsEvents/episodePlays in backup snapshots and restore
  them, so a restore no longer wipes engagement analytics.
- Regroup admin dashboard KPI cards by time scope (range vs all-time)
  and remove a duplicated Top Countries/States block.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 14:06:02 -04:00
nmemmertandClaude Sonnet 5 8496e227b3 Stop showing email-derived names in student discussions; v2.2.1
Publish Container / build (push) Successful in 14m56s
Community posts/replies fell back to the local part of the student's email
(e.g. "student.test" from student.test@versebyverse.local) whenever they
hadn't set a display name. The per-lesson discussion comments were worse —
they fell back to the FULL email address, and that endpoint has no auth
requirement, so it was publicly readable.

Adds getCommunityDisplayName() in study-helpers.js: uses the student's
display name when set, otherwise assigns a stable "Student XXXX" handle
derived from their account id — never their email. Both the community board
and lesson comments now resolve names live from the author's current
account at read time (falling back to the stored snapshot only if the
account was deleted), so this also fixes any already-posted content
without a data migration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 11:15:54 -04:00
nmemmertandClaude Sonnet 5 3d3c7175a1 Add a study-picker discussion hub and require sign-in to view it; v2.2.0
Publish Container / build (push) Successful in 16m31s
The "Discussion" nav link previously jumped straight into the featured
study's community board. Adds /study/discussion as a proper landing page
that lists every study track so students pick which one to discuss, then
links into /study/:slug/community from there. Both pages now require an
authenticated session to view at all (previously the per-study community
page rendered its shell to logged-out visitors, only gating the post feed
itself).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 10:19:20 -04:00
nmemmertandClaude Sonnet 5 c92bb2189a Fix 15 pre-existing ESLint issues in studyPages.tsx; v2.1.1
Publish Container / build (push) Successful in 16m13s
Fixes two real bugs: useEffect and usePageMeta were called after an early
"study not found" return, violating React's rules of hooks (could corrupt
hook state if a study slug resolves inconsistently across renders). Also
removes dead code (unused NOTE_ANCHORS array, an unused state pair, a stale
eslint-disable comment), fixes three return-in-finally statements that could
mask exceptions, adds the missing study?.slug effect dependency, and cleans
up an unnecessary regex escape and an empty catch block.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 09:26:03 -04:00
nmemmertandClaude Sonnet 5 841e754e16 Add student account self-service security features and discoverability fixes; v2.1.0
Adds forgot/reset password, signup email verification, sign-out-of-all-devices,
note-usage and certificate display in account settings, and password-changed
security emails. Fixes study sessions being lost on server restart by
persisting them to disk, and fixes sanitizeStudyUsers silently dropping the
new pending-token fields on load. Also surfaces the lesson discussion board
in the main site nav, trims the sign-in page for returning users, and renames
colossiansStudy.tsx/colossiansStudyData.ts to studyPages.tsx/legacyColossiansData.ts
now that the study system covers more than Colossians.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 09:21:54 -04:00
nmemmertandClaude Sonnet 5 a6e8243705 Add season field to episode scripts and improve global search; v2.0.50
Publish Container / build (push) Successful in 16m53s
Tag scripts with a season number (with an RSS-feed backfill for
existing uploads), surface it in search results, and fix word-boundary
matching plus a title/script-match dedup bug in the search merge logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 08:45:40 -04:00
nmemmertandClaude Sonnet 4.6 b236e69f22 Switch Gitea workflow back to ubuntu-latest runner; v2.0.49
Publish Container / build (push) Successful in 13m50s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-22 10:24:36 -04:00
nmemmertandClaude Sonnet 4.6 8d5a983a94 Remove QEMU, build linux/arm64 natively on ARM runner; v2.0.48
Publish Container / build (push) Failing after 22s
Runner is on the ARM server so linux/arm64 builds natively — no QEMU
or cross-compilation needed. Drop setup-qemu-action and amd64 platform.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-22 10:23:17 -04:00
nmemmertandClaude Sonnet 4.6 22b82af3de Add Gitea Actions workflow with fast-builder runner; v2.0.46
Publish Container / build (push) Failing after 5m25s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-22 09:27:48 -04:00
nmemmertandClaude Sonnet 4.6 90c72c3265 Revert Gitea Actions workflow; v2.0.45
Publish Container / docker (push) Has been cancelled
Reverts the .gitea/workflows/docker-publish.yml addition from v2.0.44.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-22 08:42:10 -04:00
nmemmertandClaude Sonnet 4.6 861b32d54c Add Gitea Actions workflow for container builds; v2.0.44
Publish Container / docker (push) Has been cancelled
Mirrors the existing .github/workflows/docker-publish.yml so Gitea
Actions picks it up from the standard .gitea/workflows/ location.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-22 08:39:17 -04:00
nmemmertandClaude Sonnet 4.6 3c62499ad5 Fix 500 on all requests: recordVisitor is sync, not a Promise; v2.0.43
Publish Container / docker (push) Successful in 15m4s
server.js was calling recordVisitor(req, res).catch() but recordVisitor
returns undefined (it's synchronous). .catch() on undefined throws
TypeError on every request, causing Express to return 500 for every page.
Swap to try/catch since the function is synchronous.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-22 08:14:57 -04:00
nmemmertandClaude Sonnet 4.6 6621c45be8 Guard startup scheduler calls against unhandled promise rejections; v2.0.42
Publish Container / docker (push) Successful in 14m48s
Replace bare `void fn()` with `fn().catch(err => console.error(...))` for all
four startup-time scheduler invocations so an unexpected rejection cannot
crash the Node.js process (which exits on unhandled rejections in v15+).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-22 08:10:58 -04:00
nmemmertandClaude Sonnet 4.6 8ba300eddc Fix host upload: handle multer errors as JSON, pre-create upload-tmp dir; v2.0.41
Publish Container / docker (push) Successful in 15m21s
If multer failed to write the temp file (e.g. data-volume permission issue),
the error was propagating unhandled through Express, returning a non-JSON 500
that the client couldn't parse — showing only "Upload failed." with no details.

Changes:
- Import mkdir and pre-create UPLOAD_TEMP_DIR on startup (non-blocking)
- Wrap upload.single('file') in a callback so multer errors return JSON
  responses with the actual message instead of a bare HTML 500

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-22 07:49:00 -04:00
nmemmertandClaude Sonnet 4.6 600c450eff Fix episode highlight save: preserve structured show-notes fields; v2.0.40
Publish Container / docker (push) Successful in 15m47s
sanitizeFeaturedLinks was silently dropping showNotesSummary, showNotesThemes,
showNotesScripture, showNotesQuotes, and showNotesResources on every save — only
the legacy showNotes string was kept. Added sanitization for all five new fields
and updated the keep-filter to include them.

Also removed set -e from entrypoint.sh and made seed/merge steps non-fatal so a
permissions error on the mounted data volume no longer crash-loops the container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-21 07:51:37 -04:00
nmemmertandClaude Sonnet 4.6 c79589b311 Add local vs R2 destination picker for admin asset uploads; v2.0.39
Publish Container / docker (push) Successful in 14m12s
Upload dropdown lets you choose R2 folder (uploads/images/guides/finished)
or local server disk. Asset table shows location column for each file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-18 12:20:21 -04:00
nmemmertandClaude Sonnet 4.6 4e6449f3a7 List all R2 objects bucket-wide so new folders are picked up automatically; v2.0.38
Publish Container / docker (push) Successful in 13m50s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-18 12:06:10 -04:00
nmemmertandClaude Sonnet 4.6 f525e7e784 Add Cloudflare R2 storage for admin asset uploads; v2.0.37
New uploads route to R2 with folder selection (uploads/images/guides/finished).
Existing local files continue to be served via express.static for backward compat.
Adds R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET,
R2_PUBLIC_URL env vars. listUploadedAssets merges R2 objects + local files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-18 12:04:55 -04:00
nmemmertandClaude Sonnet 4.6 f254e3ec19 Add scheduled series auto-launch for series card, Start Here, and study guide; v2.0.36
Publish Container / docker (push) Successful in 14m20s
One scheduled date/time in the Current Series admin panel archives the active series to
Finished Books and atomically swaps in new content for the series card, Start Here steps,
and Companion Study Guide. Server checks every hour via setInterval; also runs on restart
so the transition fires even if the server was down at the scheduled moment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-18 11:45:02 -04:00
nmemmertandClaude Sonnet 4.6 06f89d6436 Fix Scripture Index page width; v2.0.35
Publish Container / docker (push) Successful in 14m16s
.scripture-index-main had no max-width — page stretched full viewport width.
Set to 900px with auto margins to match the site standard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-18 11:13:54 -04:00
nmemmertandClaude Sonnet 4.6 a4c37d0276 Render QR code on finished series page; v2.0.34
Publish Container / docker (push) Successful in 13m37s
The downloadQrUrl field was saved but never displayed — add the QR image
below the action buttons, wrapped in a link to the download URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-18 11:12:20 -04:00
nmemmertandClaude Sonnet 4.6 e64c784c7c Standardize page content widths to 900px; v2.0.33
Publish Container / docker (push) Successful in 14m16s
- Add missing .page-inner CSS (finished series page was stretching full width)
- Fix .site-search-bar-inner: 860px → 900px
- Remove redundant max-width from .section-share-inner (inherits 900px from .section-inner)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-18 10:34:21 -04:00
nmemmertandClaude Sonnet 4.6 7ac23635b2 Add QR code field to finished books admin; v2.0.32
Publish Container / docker (push) Successful in 14m33s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-18 10:20:35 -04:00
nmemmertandClaude Sonnet 4.6 e86594b096 Add download option and CD disc artwork to finished books; v2.0.31
Publish Container / docker (push) Successful in 14m49s
- Add downloadUrl field to FinishedBook type
- Admin: paste any external URL (Drive, Dropbox, etc.) per finished book
- Public series page: replace square hero image with circular CD disc artwork
  (conic-gradient sheen, center hole, drop shadow, slight tilt)
- Stream Episodes + Download Album action buttons in the hero
- Grid cards show a gold download badge when a download URL is set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-18 10:16:42 -04:00
nmemmertandClaude Sonnet 4.6 65623ce0eb Wire up deployment metadata: build commit, number, and server start time; v2.0.30
Publish Container / docker (push) Successful in 14m11s
- Dockerfile: expose BUILD_COMMIT and BUILD_NUMBER env vars from build args
  (was COMMIT_SHA only; server was reading BUILD_COMMIT so it always fell through to null)
- CI: pass github.run_number as BUILD_NUMBER build arg
- state.js: record serverStartedAt at module load so deployedAt always shows
  when the current container instance came up, even without an env var
- admin-content.js: fall back COMMIT_SHA → BUILD_COMMIT for older images,
  use state.serverStartedAt when DEPLOYED_AT env var not set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-17 16:52:44 -04:00
nmemmertandClaude Sonnet 4.6 4002f1ee93 Analytics overhaul: privacy fixes, All Time range, readability improvements; v2.0.29
Publish Container / docker (push) Successful in 14m17s
- Remove raw IP storage; store only SHA-256 hash prefix in visitor records
- Non-blocking geo lookup: recordVisitor now synchronous, geo resolved in background
- UTM capture race condition fixed via sessionStorage pre-consent capture
- Add consent + bot checks to play endpoint (was missing)
- Add rate limiter to pageview endpoint (60 req/min per IP)
- Add 'All time' range option alongside 7d/30d/90d with trendData aggregation
- AnalyticsPanel: tabbed layout (Traffic/Engagement/System), dynamic chart titles,
  range buttons with active state, visitor log uses ipHash instead of raw IP,
  trendData drives main chart with weekly bucketing for all-time view
- Re-add prop-types dependency (required by react-simple-maps bundler)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-17 16:27:53 -04:00
nmemmertandClaude Sonnet 4.6 da5c1c5078 Remove prop-types from node_modules via npm prune; v2.0.28
Publish Container / docker (push) Failing after 10m38s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-17 16:11:47 -04:00
nmemmertandClaude Sonnet 4.6 e158e1e53a Codebase audit: remove dead code, fix broken patterns, consolidate duplicates; v2.0.27
Publish Container / docker (push) Failing after 10m40s
- Remove unused prop-types dep, deprecated useOutboundLinkTracking export
- Fix missing linkClicks key in analyticsEvents initial state (B-01)
- Export ADMIN_SESSION_COOKIE from auth.js; remove local copy in admin-auth.js (D-02)
- Remove cookieFlags() wrapper in auth.js and study-helpers.js; use cookieSecureFlag() directly (U-04)
- Remove ADMIN_SESSION_TTL_MS duplicate from config.js (D-01)
- Remove DEFAULT_SEO/DEFAULT_LEGAL/DEFAULT_REDIRECT_RULES/DEFAULT_PODCAST_FEATURED_LINKS/DEFAULT_PUBLISH_STATE
  duplicates from config.js; re-export from helpers.js (D-04, fixes sitemapPaths divergence)
- Fix email.js hardcoded fallback strings to use DEFAULT_RESEND_TO/DEFAULT_RESEND_REPLY_TO (M-02)
- Remove duplicate normalizeIp, isPrivateOrLocalIp, normalizeRedirectPath, sanitizeUrl,
  sanitizeRedirectRules bodies from study-helpers.js; re-export from helpers.js (D-03)
- Fix pruneStatsByDays to import VISITOR_RETENTION_DAYS_DEFAULT from config.js (B-02)
- Fix createTitusDownloadToken to use DOWNLOAD_TOKEN_TTL_MS constant (B-03)
- Remove normalizeMessageType duplicate from study-helpers.js (U-03/D-08)
- Remove dead wantsWelcome variable in contact.js; use shouldSendWelcome (B-04)
- Replace dynamic imports in contact.js with static imports (B-07)
- Remove dead wantsWelcome=true in study-auth.js (B-05)
- Merge duplicate import blocks in analytics.js and public.js (D-05, D-06)
- Replace dynamic readFile re-import in nuggets-host-upload.js (B-06)
- Replace hardcoded SITE_URL fallbacks with getCanonicalBaseUrl() (M-06)
- Replace raw resend.emails.send in study-account.js with sendResendEmailWithRetry (M-01)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-17 16:10:31 -04:00
nmemmertandClaude Sonnet 4.6 1ddb8ee7be Add Nuggets to global search and RSS-sourced scripture refs to index; v2.0.26
Publish Container / docker (push) Successful in 15m20s
- Raise RSS description truncation from 220 → 800 chars so scripture
  references in episode descriptions are preserved
- Global search now indexes Nuggets podcast episodes from
  /api/nuggets/episodes/all (title, subtitle, description via Fuse)
- Scripture Index (/api/public/scripture-index) now extracts verse
  references from both main podcast and Nuggets RSS descriptions
  using a regex built from the shared BIBLE_BOOKS list; duplicate refs
  across sources are merged by the existing client-side dedup logic
- Export fetchNuggetsEpisodes from nuggets.js for reuse

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-17 10:27:12 -04:00
nmemmertandClaude Sonnet 4.6 590885f6b1 Add contact form inbox to admin portal; v2.0.25
- New Inbox view in the Audience nav group shows all contact form submissions
- Filter by Unread / All / Archived with a search bar for name, email, or message
- Click any message to open a detail pane with full text, metadata, and a Reply via Email link
- Archive / Unarchive button to clear messages without deleting them
- Unread badge on the Inbox nav item; dashboard card now links directly to Inbox
- Added PATCH /api/admin-contact-submissions/:id to persist archived state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-17 10:20:49 -04:00
nmemmertandClaude Sonnet 4.6 8ff01f8e48 Raise host bio character limit from 600 to 4000; v2.0.24
Publish Container / docker (push) Successful in 13m18s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-16 09:49:25 -04:00
nmemmertandClaude Sonnet 4.6 b999a2fe86 Support multi-paragraph host bios on detail page; v2.0.23
Publish Container / docker (push) Successful in 13m57s
Split bio on blank lines to render each paragraph as its own <p> tag.
Also grows the admin textarea from 3 to 8 rows so long bios are easier
to write and review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-16 08:20:35 -04:00
nmemmertandClaude Sonnet 4.6 7ff5c453e8 Redesign Nuggets page: host pills in a row above episodes; v2.0.22
Publish Container / docker (push) Successful in 14m8s
Replaces the two-column hosts/episodes sidebar layout with a single full-width
column: host pills (photo + name, pill-shaped, clickable) in a horizontal row
at the top, episodes list filling the full width below.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-16 07:37:25 -04:00
nmemmertandClaude Sonnet 4.6 b35605f411 Add clickable host bios linking to host detail pages on Nuggets; v2.0.21
Publish Container / docker (push) Has been cancelled
Each host card on /nuggets now links to /nuggets/hosts/:hostId showing
the host's full photo, bio, and listen links. Archived hosts return a
graceful "not found" page with a back link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-16 07:32:18 -04:00
nmemmertandClaude Sonnet 4.6 4607532440 Decode HTML entities in RSS episode descriptions; v2.0.20
Publish Container / docker (push) Successful in 15m27s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-09 19:16:25 -04:00
nmemmertandClaude Sonnet 4.6 ac65db796a Show episode description in Nuggets episode list; v2.0.19
Publish Container / docker (push) Successful in 13m47s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-09 08:48:22 -04:00
nmemmertandClaude Sonnet 4.6 86de45dbc3 Make recording guidelines editable from admin panel; v2.0.18
Publish Container / docker (push) Successful in 14m23s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-09 08:38:02 -04:00
nmemmertandClaude Sonnet 4.6 ef1d9fd8fc Add full portal expansion: 2FA, change password, history, announcements, recording guidelines, Seafile link, draft saving, file validation; v2.0.17
Publish Container / docker (push) Successful in 14m36s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-09 08:08:42 -04:00
nmemmertandClaude Sonnet 4.6 afff859d17 Fix upload notification email dark text on dark background; v2.0.16
Publish Container / docker (push) Successful in 13m16s
Body text was #2c2417 inside the dark branded email wrapper. Updated
to light brand colors: #c8c0ac body, #f0ead8 emphasis, #c9a84c filename.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-08 16:01:22 -04:00
nmemmertandClaude Sonnet 4.6 51543396e3 Fix Seafile upload response parsed as JSON; v2.0.15
Publish Container / docker (push) Successful in 14m18s
Seafile seafhttp returns a plain file ID string, not JSON. Replaced
res.json() with res.text() — the return value was unused anyway.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-08 15:42:02 -04:00
nmemmertandClaude Sonnet 4.6 26a51f9089 Fix Seafile API path from /api/v2.1/ to /api2/; v2.0.14
Publish Container / docker (push) Successful in 13m54s
This Seafile instance exposes /api2/ not /api/v2.1/. Updated both the
upload-link call in the host upload route and the dir listing in the
Seafile watcher. Confirmed /api2/ works via node fetch test on the server.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-08 15:25:48 -04:00
nmemmertandClaude Sonnet 4.6 806c3bd59c Fix host welcome email using dark text on dark background; v2.0.13
Publish Container / docker (push) Successful in 13m29s
Body text was #2c2417 (near-black) rendered inside the dark branded
email wrapper (#0f0f0c). Updated to light colors matching the template:
#c8c0ac for body, #f0ead8 for emphasis, #1a1a15/#2a2518 for table cells.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-08 14:58:37 -04:00
nmemmertandClaude Sonnet 4.6 3febad351d Fix Seafile upload-link using wrong query param ?path= instead of ?p=; v2.0.12
Publish Container / docker (push) Successful in 14m0s
Seafile API uses ?p= for the directory path parameter. Using ?path= caused
the request to never match an API route, returning the web UI 404 HTML page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-08 14:45:51 -04:00
nmemmertandClaude Sonnet 4.6 ce12ae1d0d Fix reset-password email sending undefined as email field; v2.0.11
Publish Container / docker (push) Has been cancelled
Was passing data.hosts[idx].username (doesn't exist on stored hosts)
instead of data.hosts[idx].email for the welcome/reset email username param.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-08 14:41:41 -04:00
nmemmertandClaude Sonnet 4.6 4940e53a98 Fix host portal using wrong CSS variables; v2.0.10
Publish Container / docker (push) Has been cancelled
Replaced --color-bg/surface/border/input-bg fallbacks (which don't exist
in the site's CSS) with the actual --brand-* tokens so the portal
renders in the site's dark theme instead of light fallback colors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-08 14:40:47 -04:00
nmemmertandClaude Sonnet 4.6 f5c5281684 Fix build error: replace stale h.username ref with h.email; v2.0.9
Publish Container / docker (push) Successful in 13m30s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-08 14:17:55 -04:00
nmemmertandClaude Sonnet 4.6 684181c34c Use email as host login credential; remove separate username field; v2.0.8
Publish Container / docker (push) Failing after 10m44s
- Login form now asks for email + password (no username)
- Admin creates hosts with name + email + password; email is always
  required and welcome email always sends on creation
- Duplicate email check replaces duplicate username check
- Admin table shows Email (login) column only; username field removed
  throughout data model, routes, and UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-08 14:05:02 -04:00
nmemmertandClaude Sonnet 4.6 a12a471c41 Add host welcome/reset emails, email field, and /host URL; v2.0.7
- Host accounts now have an optional email field
- Creating a host with an email sends a welcome email with the portal
  URL, username, and password via Resend
- Resetting a password also emails the new credentials if the host
  has an email on file; admin sees a confirmation either way
- Admin panel shows email column and confirmation messages
- Portal URL changed from /host-upload to /host

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-08 14:01:51 -04:00
nmemmertandClaude Sonnet 4.6 1e396644a9 Add upload email notifications with Seafile direct-upload watcher; v2.0.6
- Portal uploads send one email immediately (host, episode, passage,
  description, filenames) via Resend to NUGGETS_NOTIFY_EMAIL
- Seafile watcher polls /Episode Upload every 15 min for direct uploads
  and sends a notification for any new file not already seen via portal
- Files uploaded through the portal are marked seen so the watcher
  never sends a duplicate notification
- Credentials kept out of git: SEAFILE_TOKEN, SEAFILE_NUGGETS_REPO_ID,
  NUGGETS_NOTIFY_EMAIL now sourced from docker .env file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-08 13:54:46 -04:00
nmemmertandClaude Sonnet 4.6 6a03813914 Redesign Nuggets upload portal: single episode submission form; v2.0.5
- Replace two-tab file picker with a combined form: episode name, passage,
  description, and audio file in one submit
- Server now generates an Episode Info text file (name, host, passage,
  description, timestamp) and uploads it to /Episode Info/ automatically,
  while the audio goes to /Episode Upload/
- Drop zone changes color when a valid file is selected (green border)
- Success shows submitted episode name and filename in the session log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-08 13:46:20 -04:00
nmemmertandClaude Sonnet 4.6 afdc00114a Add Nuggets host upload portal with Seafile integration; v2.0.4
- New /host-upload page: per-host login (username + password) with
  session cookie, drag-and-drop file upload to Seafile via server proxy
- Two upload targets: Episode Upload (audio) and Episode Info (docs)
- Server route: /api/nuggets-upload/* for host auth + file proxy,
  /api/admin/nuggets-upload/hosts for admin CRUD (create/disable/reset pw/delete)
- Admin panel: Nuggets → Upload Portal view to manage host accounts
- Seafile config via SEAFILE_URL / SEAFILE_TOKEN / SEAFILE_NUGGETS_REPO_ID env vars
- Directories default to /Episode Upload and /Episode Info (configurable)
- Uses multer for multipart uploads; 500 MB per-file limit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-08 13:38:57 -04:00
nmemmertandClaude Sonnet 4.6 29d2ace1b2 Fix cramped host card layout on Nuggets page; v2.0.3
Publish Container / docker (push) Successful in 12m59s
- Restructure host card: image+name/role in a row header, bio spans full card width below
- Widen hosts column from 300px max to 360px max
- Add nuggets-host-card-header and nuggets-host-card-meta CSS classes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-08 10:47:21 -04:00
nmemmertandClaude Sonnet 4.6 334b455d25 Bump to v2.0.2
Publish Container / docker (push) Successful in 13m56s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-08 09:14:52 -04:00
nmemmertandClaude Sonnet 4.6 20fbfb4772 Fix Docker image missing shared/ directory; v2.0.2
Publish Container / docker (push) Successful in 13m10s
shared/bible-books.js was added in the consolidation commit but the
Dockerfile had no COPY line for it, causing a boot crash in production.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-08 09:14:36 -04:00
nmemmertandClaude Sonnet 4.6 aac7afbdf1 Update runtime data and sync lock file to v2.0.1
Publish Container / docker (push) Successful in 13m45s
- study-users: add new user, migrate existing users to reminder-frequency schema fields
- study-reminders: bump updatedAt timestamp
- package-lock: sync version to 2.0.1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-08 08:22:12 -04:00
nmemmertandClaude Sonnet 4.6 4378fad66f Fix 5 bugs: analytics range keys, stale visitor rows, NuggetsPage init state, episodes timer leak, unsanitized nuggets object; v2.0.1
Publish Container / docker (push) Successful in 14m54s
- analytics: union byDayReal+byDayBot keys so bot-only days aren't dropped from range totals
- analytics: exclude timestamp-less visitor rows from range filters (was unconditionally including them)
- App.tsx: init `configured` to false so a network failure doesn't show a misleading "no episodes" state; add r.ok guard before r.json()
- episodes.js: wrap fetch in try/catch so clearTimeout is called on network failure, matching the nuggets.js pattern
- helpers.js: add sanitizeNuggets + wire it into sanitizeSiteContent so corrupted hosts/urls can't crash NuggetsPage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-08 08:21:02 -04:00
nmemmertandClaude Sonnet 5 31e79ad542 Add Verse By Verse: Nuggets sub-show with editable hosts and RSS feed
Publish Container / docker (push) Successful in 14m16s
New /nuggets page for the companion show, linked from the Episodes nav
dropdown. Hosts (name, role, bio, photo) show in a left column with the
episode list on the right — latest episode featured, rest below.
Episodes are pulled from an admin-configurable RSS feed URL (empty by
default, shows "coming soon" until set) rather than hardcoded like the
main show's feed, since the show doesn't have one yet.

Everything is editable in /admin under a new Nuggets section: Show
Settings (title, tagline, description, podcast art, RSS/platform URLs)
and Hosts (add freely; removing a host archives them instead of
deleting — kept on record and restorable, never lost).

Extracted the RSS item-parsing logic shared with the main show's feed
(server/routes/episodes.js) into server/rss-helpers.js so both feeds
use the same parser instead of duplicating it.

Also fixes the local Vite dev server not proxying /uploads/* to the
backend — it was silently serving the SPA shell (200, text/html)
instead of the actual file, so any admin-uploaded image (podcast art,
host photos, etc.) appeared broken in local dev only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Yvx4rPVy2HWe7ubuk3X3J
2026-09-07 14:27:52 -04:00
nmemmertandClaude Sonnet 5 335a9cbe31 Consolidate the three duplicate Bible-book lookup tables into one shared registry
Publish Container / docker (push) Successful in 14m9s
src/App.tsx (Scripture Index book map) and server/routes/bible-passage.js
(BSB/bolls.life passage fetch) each hand-maintained their own independent
66-book list with a different key format (3-letter USFM id vs bolls.life's
numeric id), so a future alias/rename fix in one would silently not apply to
the other.

Add shared/bible-books.js as the single canonical list (name + aliases +
USFM id, in canonical order); the server derives its numeric bolls.life ids
from the array position instead of hand-maintaining a second table. Requires
allowJs + including shared/ in tsconfig.app.json so the client can import the
plain-JS module alongside its TypeScript sources.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Yvx4rPVy2HWe7ubuk3X3J
2026-09-07 09:49:52 -04:00
nmemmertandClaude Sonnet 5 c158fb6317 Fix contact form crash, analytics range filter, Scripture Index parsing, and unsafe-delete UI bugs
Publish Container / docker (push) Successful in 13m12s
- server/routes/contact.js: import fireWebhooks (was undefined, throwing on every submission)
- server/routes/analytics.js: filter admin-stats by row.at, the field recordVisitor actually sets, instead of the never-populated row.visitedAt
- src/App.tsx: parseSIRef now supports chapter-spanning verse ranges (e.g. "5:15-6:2"); SI_BOOK_MAP indexes both spaced and unspaced book names so multi-word Bible Book values from the admin field resolve correctly
- src/App.css / src/App.tsx: fix Episodes/Studies nav dropdown menus being unclickable — a positioning gap between the trigger and menu created a hover dead zone; menu is now flush with spacing moved into padding so hover carries through
- src/components/StudySectionComments.tsx: check response status before removing a comment locally, so a rejected delete no longer appears to succeed
- src/components/QASection.tsx: don't bail out of search scoring for 1-2 character queries, so short substrings like "OT" or "AI" can still match
- src/AdminPage.tsx: check response status in study-user, QR code, and episode-script delete handlers before updating local state

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Yvx4rPVy2HWe7ubuk3X3J
2026-09-07 09:39:37 -04:00
nmemmertandClaude Sonnet 5 6ed29ea527 Rebuild Scripture Index as a true cross-content reference index
Publish Container / docker (push) Successful in 15m2s
Previously it just grouped study lessons under each study (essentially
another list of lessons) and ignored podcast episodes entirely. Now it
parses every reference from studies and episode show notes, merges
entries that point at the same verse(s) into one row with links to all
sources, and groups rows by book in canonical Bible order. References
that don't parse (e.g. whole-chapter refs) fall into an "Other
References" section instead of being dropped.

Also fixes the reference passed to the index for studies without a
bibleBook set — it was falling back to the study's marketing title
instead of its slug, producing unparseable references.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Yvx4rPVy2HWe7ubuk3X3J
2026-09-07 09:20:12 -04:00
nmemmertandClaude Sonnet 4.6 274adcc093 Merge feature/show-notes-form into main — v2.0.0
Publish Container / docker (push) Successful in 13m47s
- Structured show notes form for Episode Highlights (v1.2.3)
- Study account engagement: streaks, re-engagement reminders (v1.2.4)
- Redesigned study lesson UI: progress bar, sidebar nav, inline answers (v1.2.5)
- Social media community feed: likes, threading, relative timestamps (v1.2.6)
- Community shortcut in lesson sidebar (v1.2.7)
- v2.0.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-03 16:44:46 -04:00
nmemmertandClaude Sonnet 4.6 e35e5d55bb Bump to v2.0.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-03 16:41:18 -04:00
nmemmertandClaude Sonnet 4.6 53e200174f Add community shortcut to lesson sidebar; v1.2.7
Adds a "Discuss this lesson" link in the classroom sidebar that takes
the user directly to the community page pre-filtered to the current
lesson's sectionId, so they can jump into discussion without navigating
back to the study index first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-03 16:38:38 -04:00
nmemmertandClaude Sonnet 4.6 49e3a80c2c Redesign community section with social media feed UX; v1.2.6
- Twitter/X-style post cards: avatar thread line, author + relative timestamp
- Heart like button with optimistic toggle and like count per post
- Comment button with reply count; inline threaded replies always visible
- Compose card with user avatar, borderless textarea, pill Post button
- Horizontal scrollable lesson filter chips (All + per-lesson)
- New POST /api/study-community/posts/:postId/like endpoint (toggle)
- GET community posts now returns likedByMe + likeCount per post
- sanitizeStudyCommunityPosts preserves likedBy array on disk
- Relative timestamps (just now, Xm ago, Xh ago, Xd ago, then date)
- UserAvatar helper renders img or initials fallback consistently

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-03 16:34:28 -04:00
nmemmertandClaude Sonnet 4.6 874dd0c89b Redesign study lesson UI: progress bar, sidebar nav, inline answers; v1.2.5
- Progress bar: thin gold bar under lesson label showing completed/total lessons
- Sidebar lesson navigator: all lessons listed with ✓/current/○ states, replaces static Study Track + Lesson Navigation blocks
- Notes panel moves to sidebar: clicking a block's Note button opens NoteCard in sidebar instead of inline, preserving reading position
- Inline reflection answers: textareas under each question auto-save to quiz API; replaces separate quiz page link
- Note buttons: "Note" / "✎ Note" text label instead of bare pencil icon for better discoverability
- Greek Word Study block hidden when lesson has no greek notes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-03 16:01:15 -04:00
nmemmertandClaude Sonnet 4.6 038ef2b039 Add study account engagement features; v1.2.4
- Pause reminders: remindersPausedUntil field + scheduler skip + account UI with 7/14/30-day presets
- Email digest mode: reminderFrequency field (immediate/weekly) + weekly digest email + scheduler + account UI toggle
- Admin user list improvements: streak, reengagement, frequency, pause, per-study lesson progress
- Admin manual re-engagement: POST endpoint + tier selector button in admin panel
- Study bookmarks: bookmarkedSectionIds in progress file + POST/DELETE endpoints + toggle button in lesson UI
- Study completion certificate email: auto-sent on first certificate issuance
- Content fields for certificate and weekly digest email templates in admin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-03 15:16:47 -04:00
nmemmertandClaude Sonnet 4.6 5bc8245ed2 Add structured show notes form to Episode Highlights; v1.2.3
Replace single showNotes textarea with five guided fields: Episode Summary,
Key Themes, Scripture References, Key Quotes, and Resources & Links. Legacy
showNotes content is preserved as a backward-compatible fallback — it
auto-populates the Summary field in the admin editor and renders on the public
episode detail page until overwritten with structured data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-03 14:17:47 -04:00
118 changed files with 26918 additions and 12433 deletions

No files matched your search

Submodule .claude/worktrees/agent-a3dddb4d7aa52218e deleted from e17a818deb.
+71
View File
@@ -0,0 +1,71 @@
name: Publish Container
on:
push:
branches: ["main", "beta"]
tags: ["v*"]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Lint
run: npm run lint
- name: Run tests
run: npm test
- name: Run production build
run: npm run build
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.necloud.us
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: git.necloud.us/nate/siteforge
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' }}
type=sha
type=ref,event=tag
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
COMMIT_SHA=${{ github.sha }}
BUILD_NUMBER=${{ github.run_number }}
+9 -1
View File
@@ -2,7 +2,7 @@ name: Publish Container
on: on:
push: push:
branches: ["main"] branches: ["main", "beta"]
tags: ["v*"] tags: ["v*"]
workflow_dispatch: workflow_dispatch:
@@ -33,6 +33,12 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
- name: Lint
run: npm run lint
- name: Run tests
run: npm test
- name: Run production build - name: Run production build
run: npm run build run: npm run build
@@ -50,6 +56,7 @@ jobs:
images: git.necloud.us/nate/siteforge images: git.necloud.us/nate/siteforge
tags: | tags: |
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' }}
type=sha type=sha
type=ref,event=tag type=ref,event=tag
@@ -63,3 +70,4 @@ jobs:
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: | build-args: |
COMMIT_SHA=${{ github.sha }} COMMIT_SHA=${{ github.sha }}
BUILD_NUMBER=${{ github.run_number }}
+27
View File
@@ -23,11 +23,15 @@ data/visitor-stats.json
data/contact-submissions.json data/contact-submissions.json
data/totp-secret.json data/totp-secret.json
data/uploads-meta.json data/uploads-meta.json
data/study-sessions.json
# Local environment secrets — never commit # Local environment secrets — never commit
.env .env
.env.* .env.*
# Claude Code agent worktrees — local scratch space, never part of the repo
.claude/worktrees/
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
@@ -38,3 +42,26 @@ data/uploads-meta.json
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Persisted admin sessions (hashed tokens)
data/admin-sessions.json
# Per-user / visitor data — lives only in the server's data volume, never in git
data/study-users.json
data/study-notes/
data/study-progress/
data/study-reminders.json
data/study-community.json
data/analytics-events.json
data/admin-reply-history.json
data/episode-plays.json
# Episode workflow progress — lives only in the server data volume
data/podcast-workflow.json
data/nuggets-workflow.json
# Live download counters — server data volume only
data/download-counts.json
# Generated audio (upload via Admin → Asset Manager, do not commit)
audio-out/
+6 -1
View File
@@ -12,7 +12,9 @@ ENV NODE_ENV=production
ENV PORT=4173 ENV PORT=4173
ARG COMMIT_SHA=unknown ARG COMMIT_SHA=unknown
ENV COMMIT_SHA=${COMMIT_SHA} ARG BUILD_NUMBER=unknown
ENV BUILD_COMMIT=${COMMIT_SHA}
ENV BUILD_NUMBER=${BUILD_NUMBER}
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
@@ -20,6 +22,7 @@ COPY package.json ./package.json
COPY dist ./dist COPY dist ./dist
COPY server.js ./server.js COPY server.js ./server.js
COPY server/ ./server/ COPY server/ ./server/
COPY shared/ ./shared/
COPY A_Study_of_Titus.pdf ./A_Study_of_Titus.pdf COPY A_Study_of_Titus.pdf ./A_Study_of_Titus.pdf
# Copy seed data to a separate directory so the entrypoint can seed /app/data # Copy seed data to a separate directory so the entrypoint can seed /app/data
@@ -32,4 +35,6 @@ COPY entrypoint.sh ./entrypoint.sh
RUN chmod +x ./entrypoint.sh RUN chmod +x ./entrypoint.sh
EXPOSE 4173 EXPOSE 4173
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||4173)+'/api/admin-auth/status').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["./entrypoint.sh"] ENTRYPOINT ["./entrypoint.sh"]
-1
View File
@@ -141,7 +141,6 @@ siteforge/
| `POST /api/admin-contact-submissions/:id/reply` | Reply to a submission | | `POST /api/admin-contact-submissions/:id/reply` | Reply to a submission |
| `GET /api/admin/qr-codes` | QR code management | | `GET /api/admin/qr-codes` | QR code management |
| `GET /api/admin-episode-scripts/:episodeNumber` | Episode script | | `GET /api/admin-episode-scripts/:episodeNumber` | Episode script |
| `GET /api/admin-podcast-checklist` | Podcast checklist state |
| `GET /api/admin-subscribers` | Subscriber list | | `GET /api/admin-subscribers` | Subscriber list |
--- ---
+3 -3
View File
@@ -42,7 +42,7 @@
"seriesImageUrl": "/images/titus-cover.png", "seriesImageUrl": "/images/titus-cover.png",
"seriesListenUrl": "/spotify", "seriesListenUrl": "/spotify",
"seriesListenBtnLabel": "Listen to Series", "seriesListenBtnLabel": "Listen to Series",
"seriesDownloadsBtnLabel": "View Downloads", "seriesDownloadsBtnLabel": "View Resources",
"studyGuideTitle": "Companion Study Guide", "studyGuideTitle": "Companion Study Guide",
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.", "studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
"studyGuideDownloadUrl": "", "studyGuideDownloadUrl": "",
@@ -1007,7 +1007,7 @@
], ],
"shareHeading": "Help one more person hear the Word this week.", "shareHeading": "Help one more person hear the Word this week.",
"shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.", "shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.",
"prismVideoUrl": "/images/PRISM.mp4", "prismVideoUrl": "https://files.versebyversewithnate.us/images/PRISM.mp4",
"prismEyebrow": "Featured Video", "prismEyebrow": "Featured Video",
"prismHeading": "PRISM", "prismHeading": "PRISM",
"prismIntro": "What if every time you opened your Bible, you had a clear, repeatable method to actually dig in?", "prismIntro": "What if every time you opened your Bible, you had a clear, repeatable method to actually dig in?",
@@ -1030,7 +1030,7 @@
}, },
{ {
"id": "downloads", "id": "downloads",
"title": "Downloads", "title": "Resources",
"description": "Main guide, extra downloads, and past study files.", "description": "Main guide, extra downloads, and past study files.",
"path": "/resources" "path": "/resources"
}, },
+140 -7
View File
@@ -1,5 +1,6 @@
{ {
"siteContent": { "siteContent": {
"podcastArtUrl": "/images/podcast-art.jpeg",
"eyebrow": "A Journey Through Scripture", "eyebrow": "A Journey Through Scripture",
"heroTagline": "Exploring God's Word one verse at a time", "heroTagline": "Exploring God's Word one verse at a time",
"heroBtnSpotify": "Listen on Spotify", "heroBtnSpotify": "Listen on Spotify",
@@ -42,12 +43,14 @@
"seriesImageUrl": "/images/titus-cover.png", "seriesImageUrl": "/images/titus-cover.png",
"seriesListenUrl": "/spotify", "seriesListenUrl": "/spotify",
"seriesListenBtnLabel": "Listen to Series", "seriesListenBtnLabel": "Listen to Series",
"seriesDownloadsBtnLabel": "View Downloads", "seriesDownloadsBtnLabel": "View Resources",
"studyGuideTitle": "Companion Study Guide", "studyGuideTitle": "Companion Study Guide",
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.", "studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
"studyGuideDownloadUrl": "", "studyGuideDownloadUrl": "",
"studyGuideUrl": "https://a.co/d/01sG2tOJ", "studyGuideUrl": "https://a.co/d/01sG2tOJ",
"studyGuideAmazonButtonLabel": "Get it on Amazon", "studyGuideAmazonButtonLabel": "Get it on Amazon",
"studyGuideAppleBooksUrl": "",
"studyGuideAppleBooksLabel": "Get it on Apple Books",
"studies": [ "studies": [
{ {
"id": "study-colossians", "id": "study-colossians",
@@ -85,6 +88,7 @@
"Why does Paul begin with grace and peace?", "Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?" "What tone does this create for the rest of the study?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [ "checkpointQuestions": [
"Why does Paul begin with grace and peace?", "Why does Paul begin with grace and peace?",
@@ -106,6 +110,7 @@
"What signs of gospel growth does Paul mention?", "What signs of gospel growth does Paul mention?",
"How do faith, hope, and love work together here?" "How do faith, hope, and love work together here?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2026-05-31T00:00:00Z" "releasedAt": "2026-05-31T00:00:00Z"
@@ -124,6 +129,7 @@
"What does Paul ask God to produce?", "What does Paul ask God to produce?",
"How does this prayer shape the goals of the study?" "How does this prayer shape the goals of the study?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2026-12-07T00:00:00Z" "releasedAt": "2026-12-07T00:00:00Z"
@@ -142,6 +148,7 @@
"What stands out most about Christ’s supremacy?", "What stands out most about Christ’s supremacy?",
"Why is this passage central to the study?" "Why is this passage central to the study?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2026-12-21T00:00:00Z" "releasedAt": "2026-12-21T00:00:00Z"
@@ -160,6 +167,7 @@
"How does Paul describe the believer’s former condition?", "How does Paul describe the believer’s former condition?",
"What does continuing in the faith look like?" "What does continuing in the faith look like?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-01-04T00:00:00Z" "releasedAt": "2027-01-04T00:00:00Z"
@@ -178,6 +186,7 @@
"What is the goal of Paul’s ministry?", "What is the goal of Paul’s ministry?",
"Why is maturity such an important theme here?" "Why is maturity such an important theme here?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-01-18T00:00:00Z" "releasedAt": "2027-01-18T00:00:00Z"
@@ -196,6 +205,7 @@
"What does Paul want the believers to experience?", "What does Paul want the believers to experience?",
"Why does he emphasize encouragement and unity?" "Why does he emphasize encouragement and unity?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-02-01T00:00:00Z" "releasedAt": "2027-02-01T00:00:00Z"
@@ -214,6 +224,7 @@
"What does it mean to continue in Christ?", "What does it mean to continue in Christ?",
"What kinds of deception should believers watch for?" "What kinds of deception should believers watch for?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-02-15T00:00:00Z" "releasedAt": "2027-02-15T00:00:00Z"
@@ -232,6 +243,7 @@
"Why is fullness in Christ such an important correction?", "Why is fullness in Christ such an important correction?",
"How does this guard against spiritual insecurity?" "How does this guard against spiritual insecurity?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-03-01T00:00:00Z" "releasedAt": "2027-03-01T00:00:00Z"
@@ -250,6 +262,7 @@
"What changes because of Christ’s work here?", "What changes because of Christ’s work here?",
"How does victory over the powers encourage believers today?" "How does victory over the powers encourage believers today?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-03-15T00:00:00Z" "releasedAt": "2027-03-15T00:00:00Z"
@@ -268,6 +281,7 @@
"What do shadows and substance mean here?", "What do shadows and substance mean here?",
"Where do believers still feel pressure from external judgment?" "Where do believers still feel pressure from external judgment?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-03-29T00:00:00Z" "releasedAt": "2027-03-29T00:00:00Z"
@@ -286,6 +300,7 @@
"How does Paul describe false spirituality?", "How does Paul describe false spirituality?",
"Why is connection to Christ essential?" "Why is connection to Christ essential?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-04-12T00:00:00Z" "releasedAt": "2027-04-12T00:00:00Z"
@@ -304,6 +319,7 @@
"Why do human regulations fail to transform the heart?", "Why do human regulations fail to transform the heart?",
"What does real spiritual change require?" "What does real spiritual change require?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-04-26T00:00:00Z" "releasedAt": "2027-04-26T00:00:00Z"
@@ -322,6 +338,7 @@
"What does it mean to seek the things above?", "What does it mean to seek the things above?",
"How does hidden life in Christ shape daily priorities?" "How does hidden life in Christ shape daily priorities?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-05-10T00:00:00Z" "releasedAt": "2027-05-10T00:00:00Z"
@@ -340,6 +357,7 @@
"Which old patterns does Paul name?", "Which old patterns does Paul name?",
"Why is decisive change necessary?" "Why is decisive change necessary?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-05-24T00:00:00Z" "releasedAt": "2027-05-24T00:00:00Z"
@@ -358,6 +376,7 @@
"What does renewal in Christ produce?", "What does renewal in Christ produce?",
"How does this reshape how believers see one another?" "How does this reshape how believers see one another?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-06-07T00:00:00Z" "releasedAt": "2027-06-07T00:00:00Z"
@@ -376,6 +395,7 @@
"Which virtues are most needed in your context?", "Which virtues are most needed in your context?",
"How does love bind these virtues together?" "How does love bind these virtues together?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-06-21T00:00:00Z" "releasedAt": "2027-06-21T00:00:00Z"
@@ -394,6 +414,7 @@
"What does it look like for Christ’s peace to rule?", "What does it look like for Christ’s peace to rule?",
"How can the word of Christ dwell richly in a church family?" "How can the word of Christ dwell richly in a church family?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-07-05T00:00:00Z" "releasedAt": "2027-07-05T00:00:00Z"
@@ -412,6 +433,7 @@
"How do these instructions reflect Christ’s lordship?", "How do these instructions reflect Christ’s lordship?",
"Which relationship is most challenging to apply today?" "Which relationship is most challenging to apply today?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-07-19T00:00:00Z" "releasedAt": "2027-07-19T00:00:00Z"
@@ -430,6 +452,7 @@
"How does serving the Lord reshape everyday work?", "How does serving the Lord reshape everyday work?",
"What does Paul say about fairness and accountability?" "What does Paul say about fairness and accountability?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-08-02T00:00:00Z" "releasedAt": "2027-08-02T00:00:00Z"
@@ -448,6 +471,7 @@
"Why does Paul remind masters of their heavenly Master?", "Why does Paul remind masters of their heavenly Master?",
"What does fairness look like in this context?" "What does fairness look like in this context?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-08-16T00:00:00Z" "releasedAt": "2027-08-16T00:00:00Z"
@@ -466,6 +490,7 @@
"How are prayer and witness connected here?", "How are prayer and witness connected here?",
"What does gracious speech look like in real life?" "What does gracious speech look like in real life?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-08-30T00:00:00Z" "releasedAt": "2027-08-30T00:00:00Z"
@@ -484,6 +509,7 @@
"Why do these messengers matter to the letter?", "Why do these messengers matter to the letter?",
"What does this tell us about trusted coworkers?" "What does this tell us about trusted coworkers?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-09-13T00:00:00Z" "releasedAt": "2027-09-13T00:00:00Z"
@@ -502,6 +528,7 @@
"What stands out about Paul’s coworkers?", "What stands out about Paul’s coworkers?",
"How does shared ministry strengthen the church?" "How does shared ministry strengthen the church?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-09-27T00:00:00Z" "releasedAt": "2027-09-27T00:00:00Z"
@@ -520,6 +547,7 @@
"What final responsibility does Paul place on the church?", "What final responsibility does Paul place on the church?",
"How does the closing reinforce the letter’s themes?" "How does the closing reinforce the letter’s themes?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-10-11T00:00:00Z" "releasedAt": "2027-10-11T00:00:00Z"
@@ -565,6 +593,7 @@
"Why does Paul begin with grace and peace?", "Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?" "What tone does this create for the rest of the study?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [ "checkpointQuestions": [
"Why does Paul begin with grace and peace?", "Why does Paul begin with grace and peace?",
@@ -586,6 +615,7 @@
"What signs of gospel growth does Paul mention?", "What signs of gospel growth does Paul mention?",
"How do faith, hope, and love work together here?" "How do faith, hope, and love work together here?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2026-05-31T00:00:00Z" "releasedAt": "2026-05-31T00:00:00Z"
@@ -604,6 +634,7 @@
"What does Paul ask God to produce?", "What does Paul ask God to produce?",
"How does this prayer shape the goals of the study?" "How does this prayer shape the goals of the study?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2026-12-07T00:00:00Z" "releasedAt": "2026-12-07T00:00:00Z"
@@ -622,6 +653,7 @@
"What stands out most about Christ’s supremacy?", "What stands out most about Christ’s supremacy?",
"Why is this passage central to the study?" "Why is this passage central to the study?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2026-12-21T00:00:00Z" "releasedAt": "2026-12-21T00:00:00Z"
@@ -640,6 +672,7 @@
"How does Paul describe the believer’s former condition?", "How does Paul describe the believer’s former condition?",
"What does continuing in the faith look like?" "What does continuing in the faith look like?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-01-04T00:00:00Z" "releasedAt": "2027-01-04T00:00:00Z"
@@ -658,6 +691,7 @@
"What is the goal of Paul’s ministry?", "What is the goal of Paul’s ministry?",
"Why is maturity such an important theme here?" "Why is maturity such an important theme here?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-01-18T00:00:00Z" "releasedAt": "2027-01-18T00:00:00Z"
@@ -676,6 +710,7 @@
"What does Paul want the believers to experience?", "What does Paul want the believers to experience?",
"Why does he emphasize encouragement and unity?" "Why does he emphasize encouragement and unity?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-02-01T00:00:00Z" "releasedAt": "2027-02-01T00:00:00Z"
@@ -694,6 +729,7 @@
"What does it mean to continue in Christ?", "What does it mean to continue in Christ?",
"What kinds of deception should believers watch for?" "What kinds of deception should believers watch for?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-02-15T00:00:00Z" "releasedAt": "2027-02-15T00:00:00Z"
@@ -712,6 +748,7 @@
"Why is fullness in Christ such an important correction?", "Why is fullness in Christ such an important correction?",
"How does this guard against spiritual insecurity?" "How does this guard against spiritual insecurity?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-03-01T00:00:00Z" "releasedAt": "2027-03-01T00:00:00Z"
@@ -730,6 +767,7 @@
"What changes because of Christ’s work here?", "What changes because of Christ’s work here?",
"How does victory over the powers encourage believers today?" "How does victory over the powers encourage believers today?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-03-15T00:00:00Z" "releasedAt": "2027-03-15T00:00:00Z"
@@ -748,6 +786,7 @@
"What do shadows and substance mean here?", "What do shadows and substance mean here?",
"Where do believers still feel pressure from external judgment?" "Where do believers still feel pressure from external judgment?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-03-29T00:00:00Z" "releasedAt": "2027-03-29T00:00:00Z"
@@ -766,6 +805,7 @@
"How does Paul describe false spirituality?", "How does Paul describe false spirituality?",
"Why is connection to Christ essential?" "Why is connection to Christ essential?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-04-12T00:00:00Z" "releasedAt": "2027-04-12T00:00:00Z"
@@ -784,6 +824,7 @@
"Why do human regulations fail to transform the heart?", "Why do human regulations fail to transform the heart?",
"What does real spiritual change require?" "What does real spiritual change require?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-04-26T00:00:00Z" "releasedAt": "2027-04-26T00:00:00Z"
@@ -802,6 +843,7 @@
"What does it mean to seek the things above?", "What does it mean to seek the things above?",
"How does hidden life in Christ shape daily priorities?" "How does hidden life in Christ shape daily priorities?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-05-10T00:00:00Z" "releasedAt": "2027-05-10T00:00:00Z"
@@ -820,6 +862,7 @@
"Which old patterns does Paul name?", "Which old patterns does Paul name?",
"Why is decisive change necessary?" "Why is decisive change necessary?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-05-24T00:00:00Z" "releasedAt": "2027-05-24T00:00:00Z"
@@ -838,6 +881,7 @@
"What does renewal in Christ produce?", "What does renewal in Christ produce?",
"How does this reshape how believers see one another?" "How does this reshape how believers see one another?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-06-07T00:00:00Z" "releasedAt": "2027-06-07T00:00:00Z"
@@ -856,6 +900,7 @@
"Which virtues are most needed in your context?", "Which virtues are most needed in your context?",
"How does love bind these virtues together?" "How does love bind these virtues together?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-06-21T00:00:00Z" "releasedAt": "2027-06-21T00:00:00Z"
@@ -874,6 +919,7 @@
"What does it look like for Christ’s peace to rule?", "What does it look like for Christ’s peace to rule?",
"How can the word of Christ dwell richly in a church family?" "How can the word of Christ dwell richly in a church family?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-07-05T00:00:00Z" "releasedAt": "2027-07-05T00:00:00Z"
@@ -892,6 +938,7 @@
"How do these instructions reflect Christ’s lordship?", "How do these instructions reflect Christ’s lordship?",
"Which relationship is most challenging to apply today?" "Which relationship is most challenging to apply today?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-07-19T00:00:00Z" "releasedAt": "2027-07-19T00:00:00Z"
@@ -910,6 +957,7 @@
"How does serving the Lord reshape everyday work?", "How does serving the Lord reshape everyday work?",
"What does Paul say about fairness and accountability?" "What does Paul say about fairness and accountability?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-08-02T00:00:00Z" "releasedAt": "2027-08-02T00:00:00Z"
@@ -928,6 +976,7 @@
"Why does Paul remind masters of their heavenly Master?", "Why does Paul remind masters of their heavenly Master?",
"What does fairness look like in this context?" "What does fairness look like in this context?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-08-16T00:00:00Z" "releasedAt": "2027-08-16T00:00:00Z"
@@ -946,6 +995,7 @@
"How are prayer and witness connected here?", "How are prayer and witness connected here?",
"What does gracious speech look like in real life?" "What does gracious speech look like in real life?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-08-30T00:00:00Z" "releasedAt": "2027-08-30T00:00:00Z"
@@ -964,6 +1014,7 @@
"Why do these messengers matter to the letter?", "Why do these messengers matter to the letter?",
"What does this tell us about trusted coworkers?" "What does this tell us about trusted coworkers?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-09-13T00:00:00Z" "releasedAt": "2027-09-13T00:00:00Z"
@@ -982,6 +1033,7 @@
"What stands out about Paul’s coworkers?", "What stands out about Paul’s coworkers?",
"How does shared ministry strengthen the church?" "How does shared ministry strengthen the church?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-09-27T00:00:00Z" "releasedAt": "2027-09-27T00:00:00Z"
@@ -1000,6 +1052,7 @@
"What final responsibility does Paul place on the church?", "What final responsibility does Paul place on the church?",
"How does the closing reinforce the letter’s themes?" "How does the closing reinforce the letter’s themes?"
], ],
"announcement": "",
"checkpointPrompt": "", "checkpointPrompt": "",
"checkpointQuestions": [], "checkpointQuestions": [],
"releasedAt": "2027-10-11T00:00:00Z" "releasedAt": "2027-10-11T00:00:00Z"
@@ -1007,7 +1060,8 @@
], ],
"shareHeading": "Help one more person hear the Word this week.", "shareHeading": "Help one more person hear the Word this week.",
"shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.", "shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.",
"prismVideoUrl": "/images/PRISM.mp4", "shareQrImageUrl": "/images/qr-code.jpg",
"prismVideoUrl": "https://files.versebyversewithnate.us/images/PRISM.mp4",
"prismEyebrow": "Featured Video", "prismEyebrow": "Featured Video",
"prismHeading": "PRISM", "prismHeading": "PRISM",
"prismIntro": "What if every time you opened your Bible, you had a clear, repeatable method to actually dig in?", "prismIntro": "What if every time you opened your Bible, you had a clear, repeatable method to actually dig in?",
@@ -1030,7 +1084,7 @@
}, },
{ {
"id": "downloads", "id": "downloads",
"title": "Downloads", "title": "Resources",
"description": "Main guide, extra downloads, and past study files.", "description": "Main guide, extra downloads, and past study files.",
"path": "/resources" "path": "/resources"
}, },
@@ -1064,6 +1118,8 @@
"platformAmazonUrl": "/amazon", "platformAmazonUrl": "/amazon",
"platformFacebookUrl": "https://facebook.com/versebyversewithnate", "platformFacebookUrl": "https://facebook.com/versebyversewithnate",
"platformCreatorUrl": "https://creators.spotify.com/pod/profile/nmemmert/", "platformCreatorUrl": "https://creators.spotify.com/pod/profile/nmemmert/",
"platformKofiUrl": "https://ko-fi.com/versebyversewithnate",
"platformKofiQrUrl": "/images/kofi-qr.png",
"welcomeEmailSubject": "Welcome to Verse by Verse with Nate", "welcomeEmailSubject": "Welcome to Verse by Verse with Nate",
"welcomeEmailGreetingPrefix": "Glad you're here", "welcomeEmailGreetingPrefix": "Glad you're here",
"welcomeEmailIntro": "Thanks for subscribing to Verse by Verse with Nate - a Bible teaching podcast where we slow down, dig into the text, and pull out the nuggets God has for us word by word.", "welcomeEmailIntro": "Thanks for subscribing to Verse by Verse with Nate - a Bible teaching podcast where we slow down, dig into the text, and pull out the nuggets God has for us word by word.",
@@ -1082,8 +1138,52 @@
"welcomeEmailScripture": "For the grace of God has appeared, bringing salvation to all people.", "welcomeEmailScripture": "For the grace of God has appeared, bringing salvation to all people.",
"welcomeEmailScriptureRef": "Titus 2:11 - BSB", "welcomeEmailScriptureRef": "Titus 2:11 - BSB",
"welcomeEmailSignoff": "Grace and peace,\nNate", "welcomeEmailSignoff": "Grace and peace,\nNate",
"welcomeEmailSpotifyBtnLabel": "",
"welcomeEmailAppleBtnLabel": "",
"welcomeEmailStartHereLinkLabel": "",
"studyWelcomeEmailSubject": "",
"studyWelcomeEmailBody": "",
"studyWelcomeEmailCtaLabel": "",
"studyWelcomeEmailCtaPath": "",
"studyWelcomeEmailSignoff": "",
"studyDeletedEmailSubject": "",
"studyDeletedEmailBody": "",
"studyDeletedEmailCtaLabel": "",
"studyDeletedEmailCtaPath": "",
"studyDeletedEmailSignoff": "",
"studyReminderEmailSubjectPrefix": "",
"studyReminderEmailBody": "",
"studyReminderEmailCtaLabel": "",
"studyReminderEmailSignoff": "",
"studyReengagementDays": "7,14,30",
"studyReengagementT1Subject": "Your study is waiting for you",
"studyReengagementT1Body": "It's been a while — your progress is saved and your next lesson is ready whenever you are.",
"studyReengagementT1Cta": "Continue Studying",
"studyReengagementT2Subject": "Don't lose your momentum",
"studyReengagementT2Body": "Every note and completed lesson is still right there. A little each day adds up — come back and continue.",
"studyReengagementT2Cta": "Return to Your Study",
"studyReengagementT3Subject": "Your progress is still here",
"studyReengagementT3Body": "Your study progress is still intact and waiting. There's no deadline — come back whenever you're ready.",
"studyReengagementT3Cta": "Open Your Study",
"studyCertificateEmailSubject": "Your certificate is ready",
"studyCertificateEmailBody": "Congratulations — you have completed every lesson in this study. Your certificate is ready to view and share.",
"studyCertificateEmailCtaLabel": "View Certificate",
"studyCertificateEmailSignoff": "Grace and peace,\nVerse by Verse with Nate",
"studyWeeklyDigestEmailSubject": "Your weekly study digest",
"studyWeeklyDigestEmailBody": "Here are the lessons released this week in your study track. Open any to continue where you left off.",
"studyWeeklyDigestEmailSignoff": "Grace and peace,\nVerse by Verse with Nate",
"emailChangeSubject": "",
"emailChangeBody": "",
"emailChangeCtaLabel": "",
"twoFaOtpEmailSubject": "",
"twoFaOtpEmailBody": "",
"twoFaOtpEmailExpiry": "",
"qaAnsweredEmailSubject": "Your question has been answered — Verse by Verse with Nate",
"qaAnsweredEmailBody": "Nate has answered your question on Verse by Verse with Nate.",
"qaAnsweredEmailCtaLabel": "Read Full Answer →",
"qaAnsweredEmailSignoff": "Grace and peace,\nNate",
"headerFollowLabel": "Follow on Spotify", "headerFollowLabel": "Follow on Spotify",
"cookieBannerText": "We use optional analytics cookies to better understand how people find and engage with this content. By accepting, you allow us to measure how you interact with the site — no personal data is sold or shared.", "cookieBannerText": "We use optional analytics cookies to measure visits and location trends for site improvement.",
"customLinks": [ "customLinks": [
{ {
"id": "mpvh1okr", "id": "mpvh1okr",
@@ -1093,6 +1193,8 @@
"description": "We know that attending a new church can be overwhelming. Whether you are visiting for the first time or looking for ways to get more connected to Thomas Road, we want to help you take your next step with Jesus. We invite you to stop by our Connect wall (located in the main lobby) or fill out our connect form below—we'd love to meet you, answer your questions, and even offer you a welcome gift.", "description": "We know that attending a new church can be overwhelming. Whether you are visiting for the first time or looking for ways to get more connected to Thomas Road, we want to help you take your next step with Jesus. We invite you to stop by our Connect wall (located in the main lobby) or fill out our connect form below—we'd love to meet you, answer your questions, and even offer you a welcome gift.",
"amazonUrl": "", "amazonUrl": "",
"amazonLabel": "", "amazonLabel": "",
"appleBooksUrl": "",
"appleBooksLabel": "",
"tags": [], "tags": [],
"placement": "externalSites" "placement": "externalSites"
}, },
@@ -1104,12 +1206,13 @@
"description": "test test We know that attending a new church can be overwhelming. Whether you are visiting for the first time or looking for ways to get more connected to Thomas Road, we want to help you take your next step with Jesus. We invite you to stop by our Connect wall (located in the main lobby) or fill out our connect form below—we'd love to meet you, answer your questions, and even offer you a welcome gift. test test", "description": "test test We know that attending a new church can be overwhelming. Whether you are visiting for the first time or looking for ways to get more connected to Thomas Road, we want to help you take your next step with Jesus. We invite you to stop by our Connect wall (located in the main lobby) or fill out our connect form below—we'd love to meet you, answer your questions, and even offer you a welcome gift. test test",
"amazonUrl": "", "amazonUrl": "",
"amazonLabel": "", "amazonLabel": "",
"appleBooksUrl": "",
"appleBooksLabel": "",
"tags": [], "tags": [],
"placement": "externalSites" "placement": "externalSites"
} }
], ],
"customBlocks": [], "customBlocks": [],
"archivedSeries": [],
"redirects": [ "redirects": [
{ {
"id": "spotify", "id": "spotify",
@@ -1144,6 +1247,35 @@
] ]
} }
], ],
"finishedBooks": [],
"testimonials": [],
"nuggets": {
"title": "Verse By Verse: Nuggets",
"tagline": "",
"description": "Thesting this",
"podcastArtUrl": "/uploads/verse-by-verse-nuggets-cover-art-wheat-verse-1788805272517.png",
"rssFeedUrl": "",
"spotifyUrl": "",
"appleUrl": "",
"hosts": [
{
"id": "mtrk7n5r",
"name": "Nate",
"role": "Host",
"bio": "Teset",
"imageUrl": "/uploads/titus-series-1780334718378.png",
"archived": false
},
{
"id": "mtrk83tg",
"name": "Josh",
"role": "Co-Host",
"bio": "sadgfdgd",
"imageUrl": "/uploads/titus-series-1780334718378.png",
"archived": false
}
]
},
"episodesSeoIntro": "", "episodesSeoIntro": "",
"seo": { "seo": {
"title": "Verse by Verse with Nate", "title": "Verse by Verse with Nate",
@@ -1174,7 +1306,8 @@
"External links are provided for convenience and are subject to third-party policies.", "External links are provided for convenience and are subject to third-party policies.",
"By using this site, you agree to lawful use and respectful communication." "By using this site, you agree to lawful use and respectful communication."
] ]
} },
"archivedSeries": []
}, },
"updatedAt": "2026-06-03T14:17:48.087Z" "updatedAt": "2026-09-07T18:21:37.012Z"
} }
-15
View File
@@ -1,15 +0,0 @@
{
"items": [
{
"id": "da3f5f52-f0a3-4a36-9f52-7938b82e899a",
"submissionId": "0c2e3dc4-5ee3-42e9-a81b-172bedc16713",
"toEmail": "hello@versebyversewithnate.us",
"toName": "NoAdmin Signup",
"fromEmail": "hello@versebyversewithnate.us",
"subject": "Thanks for reaching out to Verse by Verse with Nate",
"preview": "Thank you for reaching out.\n\nI appreciate your message and wanted to follow up personally. I just wanted to say thank you for your email.\n\n~Nate",
"sentAt": "2026-05-07T12:54:06.683Z"
}
],
"updatedAt": "2026-05-07T12:54:06.685Z"
}
-65
View File
@@ -1,65 +0,0 @@
{
"scrollDepth": {
"/episodes": {
"25": 4,
"50": 4,
"75": 2,
"90": 0
},
"/": {
"25": 2,
"50": 2,
"75": 2,
"90": 2
},
"/about": {
"25": 6,
"50": 6,
"75": 6,
"90": 6
}
},
"timeOnPage": {
"/": {
"totalSeconds": 64,
"count": 5
},
"/episodes": {
"totalSeconds": 318967,
"count": 305
},
"/episodes/movn6tk3": {
"totalSeconds": 445,
"count": 3
},
"/about": {
"totalSeconds": 63497,
"count": 128
}
},
"outboundClicks": {},
"linkClicks": {
"https://ko-fi.com/versebyversewithnate": {
"count": 1,
"internal": false
},
"/": {
"count": 1,
"internal": true
},
"/about": {
"count": 1,
"internal": true
}
},
"utmSources": {},
"searchQueries": {},
"notFound": {},
"audioEvents": {
"Living Between Two Appearings": {
"pauses": 0,
"completions": 0,
"totalListenSeconds": 43
}
}
}
-8
View File
@@ -1,8 +0,0 @@
{
"Living Between Two Appearings": {
"total": 1,
"byDay": {
"2026-07-22": 1
}
}
}
-681
View File
@@ -1,681 +0,0 @@
{
"checklist": {
"tasks": [
{
"id": "verify_script",
"label": "Verify Script",
"phase": "pre"
},
{
"id": "read_script",
"label": "Read Script",
"phase": "pre"
},
{
"id": "record",
"label": "Record",
"phase": "pre"
},
{
"id": "edit",
"label": "Edit",
"phase": "pre"
},
{
"id": "mix",
"label": "Mix",
"phase": "pre"
},
{
"id": "video_script",
"label": "Run Video Conversion Script",
"phase": "pre"
},
{
"id": "post_spotify",
"label": "Post on Spotify",
"phase": "pre"
},
{
"id": "update_website",
"label": "Update Website",
"phase": "post"
},
{
"id": "send_email",
"label": "Send Email",
"phase": "post"
}
],
"episodes": [
{
"id": "titus-11",
"series": "Titus",
"episodeNumber": 11,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "titus-12",
"series": "Titus",
"episodeNumber": 12,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "titus-13",
"series": "Titus",
"episodeNumber": 13,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "titus-14",
"series": "Titus",
"episodeNumber": 14,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "titus-15",
"series": "Titus",
"episodeNumber": 15,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-1",
"series": "Colossians",
"episodeNumber": 1,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-2",
"series": "Colossians",
"episodeNumber": 2,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-3",
"series": "Colossians",
"episodeNumber": 3,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-4",
"series": "Colossians",
"episodeNumber": 4,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-5",
"series": "Colossians",
"episodeNumber": 5,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-6",
"series": "Colossians",
"episodeNumber": 6,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-7",
"series": "Colossians",
"episodeNumber": 7,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-8",
"series": "Colossians",
"episodeNumber": 8,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-9",
"series": "Colossians",
"episodeNumber": 9,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-10",
"series": "Colossians",
"episodeNumber": 10,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-11",
"series": "Colossians",
"episodeNumber": 11,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-12",
"series": "Colossians",
"episodeNumber": 12,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-13",
"series": "Colossians",
"episodeNumber": 13,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-14",
"series": "Colossians",
"episodeNumber": 14,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-15",
"series": "Colossians",
"episodeNumber": 15,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-16",
"series": "Colossians",
"episodeNumber": 16,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-17",
"series": "Colossians",
"episodeNumber": 17,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-18",
"series": "Colossians",
"episodeNumber": 18,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-19",
"series": "Colossians",
"episodeNumber": 19,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-20",
"series": "Colossians",
"episodeNumber": 20,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-21",
"series": "Colossians",
"episodeNumber": 21,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-22",
"series": "Colossians",
"episodeNumber": 22,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-23",
"series": "Colossians",
"episodeNumber": 23,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-24",
"series": "Colossians",
"episodeNumber": 24,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-25",
"series": "Colossians",
"episodeNumber": 25,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-26",
"series": "Colossians",
"episodeNumber": 26,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-27",
"series": "Colossians",
"episodeNumber": 27,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "episode-mpb66z1c",
"series": "Titus -10",
"episodeNumber": 0,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
}
]
},
"updatedAt": "2026-05-18T12:18:35.130Z"
}
-50
View File
@@ -1,50 +0,0 @@
{
"posts": [
{
"id": "fd6f2d9c-8abc-43b2-8e93-c58c0cb1ff7b",
"studySlug": "colossians",
"sectionId": "",
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"authorName": "Test Student",
"message": "reading",
"createdAt": "2026-06-02T17:41:48.684Z",
"replies": []
},
{
"id": "001f2545-e1ee-4748-902b-5da5c47554bb",
"studySlug": "colossians",
"sectionId": "",
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"authorName": "Test Student",
"message": "sool",
"createdAt": "2026-06-02T17:41:41.608Z",
"replies": []
},
{
"id": "c40e4c10-47a3-42e8-88bd-78116af5babe",
"studySlug": "colossians",
"sectionId": "",
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"authorName": "Test Student",
"message": "test",
"createdAt": "2026-06-02T17:41:16.795Z",
"replies": [
{
"id": "efe0bf3d-66e8-4dad-98c1-2e8c742e3120",
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"authorName": "Test Student",
"message": "test",
"createdAt": "2026-06-02T17:41:25.003Z"
},
{
"id": "1d6c3bd1-5ada-4f98-8ca3-5ed15bd00a68",
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"authorName": "Test Student",
"message": "test",
"createdAt": "2026-06-02T17:41:34.406Z"
}
]
}
],
"updatedAt": "2026-06-02T17:41:48.685Z"
}
@@ -1,3 +0,0 @@
{
"colossians--1-1-2": "test....teset"
}
@@ -1,3 +0,0 @@
{
"1-1-2": "Test"
}
@@ -1,17 +0,0 @@
{
"byStudy": {
"colossians": {
"completedSectionIds": [
"1-1-2",
"1-3-8"
],
"quizAnswers": {
"1-1-2": [
"test",
"test"
]
}
}
},
"updatedAt": "2026-06-08T20:30:19.689Z"
}
-4
View File
@@ -1,4 +0,0 @@
{
"users": {},
"updatedAt": "2026-09-02T17:00:36.458Z"
}
-73
View File
@@ -1,73 +0,0 @@
{
"users": [
{
"id": "9b74971e-bfdc-4e64-a8d7-87cd8f2bb5c4",
"username": "student.test@versebyverse.local",
"passwordHash": "6500cc2e717d1d646f371dba1ec918d2f2f9fddbb8576c792df69a1f2340dcd9",
"displayName": "Student Test",
"subscribeNewsletter": false,
"studyRemindersEnabled": false,
"enrolledStudySlugs": [],
"avatarUrl": "",
"createdAt": null,
"updatedAt": "2026-08-10T12:39:25.413Z",
"lastLoginAt": "2026-05-21T15:41:16.259Z",
"reengagementSentAt": {},
"pendingEmailChange": null,
"twoFaMethod": null,
"totpSecret": null,
"totpVerified": false,
"totpEnabledAt": null,
"totpRecoveryCodes": [],
"birthdayMonth": 7,
"birthdayDay": 4,
"birthdayAlternateMonth": null,
"birthdayAlternateDay": null,
"birthdayEmailSentYear": null
},
{
"id": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"username": "test.user@versebyverse.local",
"passwordHash": "fc89481d72dbea9af1d4f054faad48813227fa018da39259272dd27b5e493bb5",
"displayName": "Test Student",
"subscribeNewsletter": false,
"studyRemindersEnabled": false,
"enrolledStudySlugs": [
"colossians"
],
"avatarUrl": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2288%22%20height%3D%2288%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20rx%3D%2218%22%20ry%3D%2218%22%20fill%3D%22%2311100d%22%2F%3E%3Ctext%20x%3D%2250%25%22%20y%3D%2250%25%22%20dominant-baseline%3D%22middle%22%20text-anchor%3D%22middle%22%20font-size%3D%2256%22%3E%F0%9F%A6%81%3C%2Ftext%3E%3C%2Fsvg%3E",
"createdAt": null,
"updatedAt": "2026-06-08T20:46:30.804Z",
"lastLoginAt": "2026-06-08T20:46:30.804Z",
"reengagementSentAt": {},
"pendingEmailChange": null,
"twoFaMethod": null,
"totpSecret": null,
"totpVerified": false,
"totpEnabledAt": null,
"totpRecoveryCodes": [],
"totpSecretPending": "FGR7UI5TWA4HB4DURCXEUXATTVVVJDJ5"
},
{
"id": "81c01e32-d94e-4e19-93ea-e5cd7644ea6a",
"username": "test.avatar@versebyverse.local",
"passwordHash": "fc89481d72dbea9af1d4f054faad48813227fa018da39259272dd27b5e493bb5",
"displayName": "Avatar Test",
"subscribeNewsletter": false,
"studyRemindersEnabled": false,
"enrolledStudySlugs": [],
"avatarUrl": "data:image/svg+xml;utf8,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"88\" height=\"88\"%3E%3Crect width=\"100%\" height=\"100%\" rx=\"18\" ry=\"18\" fill=\"%2311100d\"/%3E%3Ctext x=\"50%\" y=\"50%\" dominant-baseline=\"middle\" text-anchor=\"middle\" font-size=\"56\"%3E🐱%3C/text%3E%3C/svg%3E",
"createdAt": null,
"updatedAt": null,
"lastLoginAt": "2026-06-02T17:33:13.669Z",
"reengagementSentAt": {},
"pendingEmailChange": null,
"twoFaMethod": null,
"totpSecret": null,
"totpVerified": false,
"totpEnabledAt": null,
"totpRecoveryCodes": []
}
],
"updatedAt": "2026-08-10T12:39:25.413Z"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

+7
View File
@@ -10,6 +10,13 @@ services:
- SITEFORGE_DATA_DIR=/app/data - SITEFORGE_DATA_DIR=/app/data
- ADMIN_PASSWORD=`generate a random password and set it here` - ADMIN_PASSWORD=`generate a random password and set it here`
- RESEND_API_KEY=`set your Resend API key here` - RESEND_API_KEY=`set your Resend API key here`
# Seafile integration for Nuggets host upload portal
# Set SEAFILE_TOKEN and SEAFILE_NUGGETS_REPO_ID in a .env file alongside this compose file
- SEAFILE_URL=https://seafile.necloud.us
- SEAFILE_TOKEN=${SEAFILE_TOKEN}
- SEAFILE_NUGGETS_REPO_ID=${SEAFILE_NUGGETS_REPO_ID}
# Upload notification email (defaults to RESEND_TO if not set)
- NUGGETS_NOTIFY_EMAIL=${NUGGETS_NOTIFY_EMAIL}
volumes: volumes:
- /media/ZimaOS-HD/AppData/siteforge/data:/app/data - /media/ZimaOS-HD/AppData/siteforge/data:/app/data
restart: unless-stopped restart: unless-stopped
+6 -7
View File
@@ -1,11 +1,10 @@
#!/bin/sh #!/bin/sh
set -e
DATA_DIR=${SITEFORGE_DATA_DIR:-/app/data} DATA_DIR=${SITEFORGE_DATA_DIR:-/app/data}
SEED_DIR=/app/data-seed SEED_DIR=/app/data-seed
# Ensure the data directory exists (in case the volume was not mounted) # Ensure the data directory exists (in case the volume was not mounted)
mkdir -p "$DATA_DIR" mkdir -p "$DATA_DIR" || true
# Seed each default JSON file only when missing. # Seed each default JSON file only when missing.
# This runs on first boot or a fresh volume, but never overwrites existing data. # This runs on first boot or a fresh volume, but never overwrites existing data.
@@ -18,7 +17,7 @@ if [ -d "$SEED_DIR" ]; then
if [ ! -f "$target_file" ]; then if [ ! -f "$target_file" ]; then
echo "[siteforge] No $base_name found. Seeding from image defaults..." echo "[siteforge] No $base_name found. Seeding from image defaults..."
cp "$seed_file" "$target_file" cp "$seed_file" "$target_file" || echo "[siteforge] Warning: could not seed $base_name — check volume permissions on $DATA_DIR"
fi fi
done done
fi fi
@@ -99,8 +98,8 @@ console.log(`[siteforge] Merged seeded platform links into ${targetFile}`)
NODE NODE
} }
merge_seed_platform_links "$DATA_DIR/admin-content.json" "$SEED_DIR/admin-content.json" merge_seed_platform_links "$DATA_DIR/admin-content.json" "$SEED_DIR/admin-content.json" || true
merge_seed_platform_links "$DATA_DIR/admin-content-draft.json" "$SEED_DIR/admin-content-draft.json" merge_seed_platform_links "$DATA_DIR/admin-content-draft.json" "$SEED_DIR/admin-content-draft.json" || true
# Backfill missing lesson releasedAt values from seeded defaults on upgrades. # Backfill missing lesson releasedAt values from seeded defaults on upgrades.
# This keeps persistent volumes compatible with release-date access gating. # This keeps persistent volumes compatible with release-date access gating.
@@ -207,8 +206,8 @@ console.log(`[siteforge] Backfilled missing releasedAt values in ${targetFile}`)
NODE NODE
} }
merge_seed_study_release_dates "$DATA_DIR/admin-content.json" "$SEED_DIR/admin-content.json" merge_seed_study_release_dates "$DATA_DIR/admin-content.json" "$SEED_DIR/admin-content.json" || true
merge_seed_study_release_dates "$DATA_DIR/admin-content-draft.json" "$SEED_DIR/admin-content-draft.json" merge_seed_study_release_dates "$DATA_DIR/admin-content-draft.json" "$SEED_DIR/admin-content-draft.json" || true
export SITEFORGE_DATA_DIR="$DATA_DIR" export SITEFORGE_DATA_DIR="$DATA_DIR"
exec node server.js exec node server.js
+4
View File
@@ -19,5 +19,9 @@ export default defineConfig([
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
}, },
rules: {
// Reset-state-on-prop-change effects are used throughout; flagged for gradual cleanup rather than blocking lint.
'react-hooks/set-state-in-effect': 'warn',
},
}, },
]) ])
+1843 -1234
View File
File diff suppressed because it is too large. Load diff
+10 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "1.2.2", "version": "2.46.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -10,20 +10,26 @@
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"start": "node --env-file=.env server.js", "start": "node --env-file=.env server.js",
"lint": "eslint .", "lint": "eslint .",
"test": "node --test test/*.test.js",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1135.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@tiptap/react": "^3.27.0", "@tiptap/core": "^3.31.4",
"@tiptap/starter-kit": "^3.27.0", "@tiptap/pm": "^3.31.4",
"@tiptap/react": "^3.31.4",
"@tiptap/starter-kit": "^3.31.4",
"chart.js": "^4.4.0", "chart.js": "^4.4.0",
"docx": "^9.6.1", "docx": "^9.6.1",
"dompurify": "^3.4.16",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.4.1", "express-rate-limit": "^8.4.1",
"fuse.js": "^7.4.2", "fuse.js": "^7.4.2",
"mammoth": "^1.12.0", "mammoth": "^1.12.0",
"multer": "^2.3.0",
"otplib": "^13.4.0", "otplib": "^13.4.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@@ -32,7 +38,7 @@
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1", "react-router-dom": "^7.13.1",
"react-simple-maps": "^3.0.0", "react-simple-maps": "^5.0.5",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"resend": "^6.10.0", "resend": "^6.10.0",
"tar": "^7.5.19" "tar": "^7.5.19"
+26
View File
@@ -0,0 +1,26 @@
# Siteforge TODO
Open gaps found in the beta audit (2026-10-09). Items already shipped are in git history (v2.10.0 – v2.10.2).
## Security
- [ ] **Email 2FA codes use `Math.random()`** — switch `generateEmailOtp` in `server/study-helpers.js` to `crypto.randomInt`; compare hashes with `timingSafeEqual`.
- [ ] **TOTP replay** — `verifyTotpCode` in `server/auth.js` accepts a code again within its ±1 step window. Record the last accepted time step per secret (admin + study users) and reject anything at or before it.
- [ ] **`unhandledRejection` / `uncaughtException` handlers** in `server.js` — Node 22 exits on an unhandled rejection; log and (for rejections) keep running.
- [ ] **Scrub old user data from git history** — `data/study-users.json` (old SHA-256 password hashes), study notes/progress etc. were committed before being untracked in v2.10.0. Needs a history rewrite (`git filter-repo`) + force-push; old hashes remain recoverable until then.
- [ ] **CSP `'unsafe-inline'` in `script-src`** — needs a nonce setup for the Vite build to remove.
## Frontend / performance
- [ ] **Code splitting** — no `lazy()`/`Suspense` anywhere; one 1.7 MB JS bundle includes the 7,000-line `AdminPage`. Lazy-load `AdminPage` and the study pages.
- [ ] **React error boundary** — a render error currently white-screens the page; add a top-level boundary with a fallback.
- [ ] **Per-route SEO / link previews** — OG tags are fixed in `index.html` and titles are set client-side (`usePageMeta`), so non-JS crawlers (iMessage, Facebook, Slack) show the generic preview for `/episodes/:id` and study lesson URLs. Inject per-route title/description/OG tags server-side.
- [ ] **Accessibility audit** — run axe (contrast, focus order, modal dialogs, image alt text).
## Maintenance
- [ ] **Split the large files** — `AdminPage.tsx` (~7,000 lines), `studyPages.tsx` (~4,200), `App.tsx` (~3,600).
- [ ] **Clear the 11 `react-hooks/set-state-in-effect` lint warnings** (rule downgraded to `warn` in v2.10.0), then restore it to `error`.
- [ ] **Remaining npm audit findings** — `d3-color` (high, via `react-simple-maps`) and `sprintf-js` (moderate, via `mammoth`; fix is a breaking downgrade).
- [ ] **CI installs with `--legacy-peer-deps`** — resolve the tiptap peer-dependency conflict so plain `npm ci` works.
- [ ] **Resend webhook `?token=` query form** — in logs/URLs; kept because Resend can't send custom headers. Revisit if Resend adds header support or switch to svix signature verification.
-18
View File
@@ -1,18 +0,0 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY server.js ./
COPY public/ ./public/
VOLUME ["/data"]
ENV PORT=3000
ENV DATA_FILE=/data/progress.json
EXPOSE 3000
CMD ["node", "server.js"]
-63
View File
@@ -1,63 +0,0 @@
# Podcast Checklist
Episode production tracker for *Verse by Verse with Nate*.
## Deploy
### 1. Build and push to GHCR
```bash
docker build -t ghcr.io/nmemmert/podcast-checklist:latest .
docker push ghcr.io/nmemmert/podcast-checklist:latest
```
### 2. On the Oracle Cloud VM
```bash
docker pull ghcr.io/nmemmert/podcast-checklist:latest
docker rm -f podcast-checklist 2>/dev/null || true
docker run -d \
--name podcast-checklist \
--restart unless-stopped \
-p 3001:3000 \
-v podcast-checklist-data:/data \
ghcr.io/nmemmert/podcast-checklist:latest
```
> Uses port **3001** on the host so it doesn't clash with your existing site on 3000.
> Progress is saved to a named Docker volume (`podcast-checklist-data`) so it survives container restarts and re-deploys.
### 3. Cloudflare DNS
Add a CNAME record in Cloudflare:
- **Name:** `checklist` (or whatever subdomain you want)
- **Target:** `versebyversewithnate.us`
- **Proxy:** Enabled (orange cloud)
### 4. Cloudflare Tunnel or Nginx proxy
If you're using Nginx Proxy Manager or a Cloudflare Tunnel on the VM, add a rule to forward `checklist.versebyversewithnate.us` → `localhost:3001`.
---
## Update workflow
Same as your site:
```bash
# Local
docker build -t ghcr.io/nmemmert/podcast-checklist:latest .
docker push ghcr.io/nmemmert/podcast-checklist:latest
# SSH into VM
docker pull ghcr.io/nmemmert/podcast-checklist:latest
docker rm -f podcast-checklist
docker run -d \
--name podcast-checklist \
--restart unless-stopped \
-p 3001:3000 \
-v podcast-checklist-data:/data \
ghcr.io/nmemmert/podcast-checklist:latest
```
Progress data is in the named volume and is **not affected** by pulling a new image.
-12
View File
@@ -1,12 +0,0 @@
{
"name": "podcast-checklist",
"version": "1.0.0",
"description": "Verse by Verse with Nate — Episode Production Checklist",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2"
}
}
-401
View File
@@ -1,401 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Episode Checklist — Verse by Verse with Nate</title>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,600&display=swap" rel="stylesheet" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--black: #0d0d0a;
--card-bg: #111108;
--border: #1e1c14;
--border-mid: #2a2518;
--gold: #c9a84c;
--gold-light: #e0c070;
--warm-white: #f0ead8;
--warm-gray: #7a7060;
--muted: #4a4438;
--dim: #3a3020;
--deep: #252018;
}
body { background: var(--black); color: var(--warm-white); font-family: 'Cormorant Garamond', Georgia, serif; min-height: 100vh; }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: var(--black); }
::-webkit-scrollbar-thumb { background: var(--border-mid); }
.root { max-width: 720px; margin: 0 auto; padding: 48px 20px 100px; }
.masthead { text-align: center; margin-bottom: 40px; }
.eyebrow { font-size: 10px; letter-spacing: 0.3em; text-transform: uppercase; color: var(--warm-gray); margin-bottom: 10px; }
.main-title { font-size: clamp(28px, 6vw, 44px); font-weight: 300; font-style: italic; color: var(--gold); line-height: 1.1; margin-bottom: 4px; }
.subtitle { font-size: 13px; color: var(--muted); letter-spacing: 0.1em; }
.gold-rule { width: 80px; height: 1px; background: linear-gradient(90deg, transparent, var(--gold), transparent); margin: 18px auto; }
.save-status { text-align: center; font-size: 11px; letter-spacing: 0.12em; color: var(--dim); margin-bottom: 24px; height: 16px; transition: color 0.3s; }
.save-status.saving { color: var(--warm-gray); }
.save-status.saved { color: var(--gold); }
.save-status.error { color: #8b3030; }
.overall { background: var(--card-bg); border: 1px solid var(--border); border-radius: 2px; padding: 18px 22px; margin-bottom: 32px; }
.overall-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 10px; }
.overall-label { font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--warm-gray); }
.overall-pct { font-size: 28px; font-weight: 300; color: var(--gold); }
.bar-wrap { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; margin-bottom: 14px; }
.bar-fill { height: 100%; background: linear-gradient(90deg, var(--gold), var(--gold-light)); border-radius: 2px; transition: width 0.4s ease; }
.series-stats { display: flex; gap: 24px; flex-wrap: wrap; }
.stat { font-size: 12px; color: var(--muted); }
.stat b { color: var(--gold); font-weight: 500; }
.filters { display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap; }
.filter-btn { font-family: 'Cormorant Garamond', serif; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; background: none; border: 1px solid var(--border-mid); color: var(--muted); padding: 6px 14px; border-radius: 1px; cursor: pointer; transition: all 0.15s; }
.filter-btn:hover { color: var(--gold); border-color: var(--dim); }
.filter-btn.active { color: var(--gold); border-color: var(--gold); background: var(--card-bg); }
.section-label { font-size: 10px; letter-spacing: 0.3em; text-transform: uppercase; color: var(--dim); margin: 28px 0 12px; padding-left: 2px; }
/* Card */
.card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 2px; margin-bottom: 8px; overflow: hidden; transition: border-color 0.2s; }
.card:hover { border-color: var(--border-mid); }
.card.complete { border-left: 2px solid var(--gold); }
.card-header { display: flex; align-items: center; gap: 12px; padding: 14px 18px; cursor: pointer; user-select: none; }
.series-tag { font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; padding: 2px 7px; border-radius: 1px; flex-shrink: 0; }
.tag-Titus { color: var(--gold); background: #1a1810; border: 1px solid var(--border-mid); }
.tag-Colossians { color: var(--warm-gray); background: #131310; border: 1px solid var(--border); }
.ep-num { font-size: 13px; font-weight: 600; color: var(--muted); flex-shrink: 0; min-width: 36px; }
.card.complete .ep-num { color: var(--gold); }
.ep-label { flex: 1; font-size: 15px; color: #c8c0a8; }
.card.complete .ep-label { color: var(--muted); }
.pub-date-pill { font-size: 10px; color: var(--warm-gray); font-style: italic; flex-shrink: 0; letter-spacing: 0.03em; }
.task-count { font-size: 11px; color: var(--dim); flex-shrink: 0; }
.card.complete .task-count { color: var(--gold); }
.mini-bar { width: 48px; height: 2px; background: var(--border); border-radius: 1px; overflow: hidden; flex-shrink: 0; }
.mini-fill { height: 100%; background: linear-gradient(90deg, var(--gold), var(--gold-light)); transition: width 0.3s; }
.chevron { font-size: 10px; color: var(--border-mid); transition: transform 0.2s, color 0.15s; flex-shrink: 0; }
.chevron.open { transform: rotate(180deg); color: var(--muted); }
.card-body { border-top: 1px solid #1a1810; padding: 0 18px 18px; }
/* Date published row */
.date-row { display: flex; align-items: center; gap: 12px; padding: 14px 0 12px; border-bottom: 1px solid var(--border); margin-bottom: 4px; }
.date-label { font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--warm-gray); flex-shrink: 0; }
.date-input {
background: transparent; border: none; border-bottom: 1px solid var(--border-mid);
outline: none; font-family: 'Cormorant Garamond', serif; font-size: 14px;
color: var(--gold-light); padding: 2px 6px 3px; cursor: pointer;
color-scheme: dark;
}
.date-input:focus { border-bottom-color: var(--gold); }
.date-input::-webkit-calendar-picker-indicator { filter: invert(0.6) sepia(1) saturate(2) hue-rotate(5deg); cursor: pointer; }
/* Phase headers */
.phase-header { font-size: 10px; letter-spacing: 0.25em; text-transform: uppercase; color: var(--warm-gray); padding: 14px 0 6px; border-bottom: 1px solid var(--border); margin-bottom: 2px; display: flex; align-items: center; gap: 8px; }
.phase-header::after { content: ''; flex: 1; height: 1px; background: var(--border); }
.phase-pre { color: var(--warm-gray); }
.phase-post { color: var(--gold); }
.task-list { list-style: none; }
.task-item { display: flex; align-items: flex-start; gap: 12px; padding: 9px 0; border-bottom: 1px solid #141210; cursor: pointer; }
.task-item:last-child { border-bottom: none; }
.check-box { width: 16px; height: 16px; border: 1px solid var(--border-mid); border-radius: 1px; flex-shrink: 0; margin-top: 3px; display: flex; align-items: center; justify-content: center; transition: background 0.15s, border-color 0.15s; }
.task-item.done .check-box { background: var(--gold); border-color: var(--gold); }
.check-svg { display: none; }
.task-item.done .check-svg { display: block; }
.step-num { font-size: 9px; letter-spacing: 0.15em; color: var(--border-mid); margin-top: 4px; flex-shrink: 0; width: 22px; }
.task-body { flex: 1; }
.task-name { font-size: 14px; font-weight: 500; color: #d8d0b8; line-height: 1.3; transition: color 0.15s; }
.task-item.done .task-name { color: var(--dim); text-decoration: line-through; text-decoration-color: var(--border-mid); }
.task-hint { font-size: 12px; color: var(--muted); font-style: italic; margin-top: 1px; }
.task-item.done .task-hint { color: var(--deep); }
.task-item:hover .task-name { color: var(--warm-white); }
.task-item.done:hover .task-name { color: var(--dim); }
.card-footer { display: flex; justify-content: flex-end; padding-top: 10px; margin-top: 4px; border-top: 1px solid #141210; }
.btn-reset { font-family: 'Cormorant Garamond', serif; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; background: none; border: 1px solid var(--border); color: var(--dim); padding: 4px 10px; border-radius: 1px; cursor: pointer; transition: all 0.15s; }
.btn-reset:hover { color: var(--warm-gray); border-color: var(--border-mid); }
.complete-badge { font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--gold); padding: 2px 8px; border: 1px solid var(--border-mid); border-radius: 1px; }
.loading { text-align: center; color: var(--muted); font-style: italic; padding: 40px; font-size: 15px; }
</style>
</head>
<body>
<div class="root">
<div class="masthead">
<div class="eyebrow">Verse by Verse with Nate</div>
<div class="main-title">Production Checklist</div>
<div class="subtitle">Titus · Colossians</div>
<div class="gold-rule"></div>
</div>
<div class="save-status" id="saveStatus"></div>
<div class="overall">
<div class="overall-row">
<div class="overall-label">Overall Progress</div>
<div class="overall-pct" id="overallPct">0%</div>
</div>
<div class="bar-wrap"><div class="bar-fill" id="overallBar" style="width:0%"></div></div>
<div class="series-stats">
<div class="stat">Titus — <b id="titusDone">0</b> / 5 episodes done</div>
<div class="stat">Colossians — <b id="colossDone">0</b> / 27 episodes done</div>
</div>
</div>
<div class="filters">
<button class="filter-btn active" onclick="setFilter('All')">All</button>
<button class="filter-btn" onclick="setFilter('Titus')">Titus</button>
<button class="filter-btn" onclick="setFilter('Colossians')">Colossians</button>
</div>
<div id="app"><div class="loading">Loading…</div></div>
</div>
<script>
// ── Task definitions ────────────────────────────────────────────────────────
const PRE_TASKS = [
{ id: "verify_script", step: "01", label: "Verify Script", desc: "Review for accuracy, theology, and flow" },
{ id: "read_script", step: "02", label: "Read Script", desc: "Read aloud, mark pacing & emphasis" },
{ id: "record", step: "03", label: "Record", desc: "Capture final audio take" },
{ id: "edit", step: "04", label: "Edit", desc: "Trim, cut, clean up in Ferrite / GarageBand" },
{ id: "mix", step: "05", label: "Mix", desc: "Levels, EQ, music beds, final master" },
{ id: "video_script", step: "06", label: "Run Video Conversion Script", desc: "Generate video file from audio" },
{ id: "post_spotify", step: "07", label: "Post on Spotify", desc: "Upload with show notes & discussion questions" },
];
const POST_TASKS = [
{ id: "update_website", step: "08", label: "Update Website", desc: "Add episode highlights to versebyversewithnate.us" },
{ id: "send_email", step: "09", label: "Send Email", desc: "Send newsletter to subscribers via Resend" },
];
const ALL_TASK_IDS = [...PRE_TASKS, ...POST_TASKS].map(t => t.id);
function makeEpisode(series, number) {
const tasks = {};
ALL_TASK_IDS.forEach(id => tasks[id] = false);
return { id: `${series}-${number}`, series, number, tasks, datePublished: "", expanded: false };
}
function buildEpisodes() {
const titus = [11,12,13,14,15].map(n => makeEpisode("Titus", n));
const colossians = Array.from({ length: 27 }, (_, i) => makeEpisode("Colossians", i + 1));
return [...titus, ...colossians];
}
// ── State ───────────────────────────────────────────────────────────────────
let episodes = [];
let activeFilter = "All";
let saveTimer = null;
// ── Server I/O ──────────────────────────────────────────────────────────────
async function loadFromServer() {
try {
const res = await fetch("/api/progress");
const saved = await res.json();
const base = buildEpisodes();
episodes = base.map(ep => {
const s = saved[ep.id];
if (!s) return ep;
return {
...ep,
expanded: s.expanded ?? false,
datePublished: s.datePublished ?? "",
tasks: Object.fromEntries(ALL_TASK_IDS.map(id => [id, s.tasks?.[id] ?? false]))
};
});
} catch {
episodes = buildEpisodes();
}
render();
}
function scheduleSave() {
clearTimeout(saveTimer);
setStatus("saving", "Saving…");
saveTimer = setTimeout(saveToServer, 800);
}
async function saveToServer() {
const payload = {};
episodes.forEach(ep => {
payload[ep.id] = { expanded: ep.expanded, datePublished: ep.datePublished, tasks: ep.tasks };
});
try {
await fetch("/api/progress", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
setStatus("saved", "✦ Saved");
setTimeout(() => setStatus("", ""), 2000);
} catch {
setStatus("error", "Save failed — check connection");
}
}
function setStatus(cls, msg) {
const el = document.getElementById("saveStatus");
el.className = "save-status" + (cls ? " " + cls : "");
el.textContent = msg;
}
// ── Mutations ────────────────────────────────────────────────────────────────
function toggleTask(epId, taskId) {
const ep = episodes.find(e => e.id === epId);
ep.tasks[taskId] = !ep.tasks[taskId];
scheduleSave(); render();
}
function toggleExpand(epId) {
const ep = episodes.find(e => e.id === epId);
ep.expanded = !ep.expanded;
scheduleSave(); render();
}
function setDate(epId, val) {
const ep = episodes.find(e => e.id === epId);
ep.datePublished = val;
scheduleSave(); render();
}
function resetEpisode(epId) {
const ep = episodes.find(e => e.id === epId);
ALL_TASK_IDS.forEach(id => ep.tasks[id] = false);
ep.datePublished = "";
scheduleSave(); render();
}
function setFilter(f) {
activeFilter = f;
document.querySelectorAll(".filter-btn").forEach(btn =>
btn.classList.toggle("active", btn.textContent === f)
);
render();
}
// ── Render ───────────────────────────────────────────────────────────────────
function render() {
updateStats();
const app = document.getElementById("app");
const filtered = activeFilter === "All" ? episodes : episodes.filter(e => e.series === activeFilter);
const titus = filtered.filter(e => e.series === "Titus");
const colossians = filtered.filter(e => e.series === "Colossians");
let html = "";
if (titus.length) {
html += `<div class="section-label">Titus — Episodes 11–15</div>`;
titus.forEach(ep => html += renderCard(ep));
}
if (colossians.length) {
html += `<div class="section-label">Colossians — Episodes 1–27</div>`;
colossians.forEach(ep => html += renderCard(ep));
}
app.innerHTML = html;
}
function formatDate(val) {
if (!val) return "";
const [y, m, d] = val.split("-");
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
return `${months[parseInt(m)-1]} ${parseInt(d)}, ${y}`;
}
function renderCard(ep) {
const allDone = ALL_TASK_IDS.every(id => ep.tasks[id]);
const doneCount = ALL_TASK_IDS.filter(id => ep.tasks[id]).length;
const total = ALL_TASK_IDS.length;
const pct = Math.round((doneCount / total) * 100);
const dateDisplay = ep.datePublished ? formatDate(ep.datePublished) : "";
let body = "";
if (ep.expanded) {
// Pre-publish tasks
const preTasks = PRE_TASKS.map(t => renderTask(ep.id, t, ep.tasks[t.id])).join("");
// Post-publish tasks
const postTasks = POST_TASKS.map(t => renderTask(ep.id, t, ep.tasks[t.id])).join("");
const footer = allDone
? `<span class="complete-badge">✦ Complete</span>`
: `<button class="btn-reset" onclick="event.stopPropagation();resetEpisode('${ep.id}')">Reset</button>`;
body = `
<div class="card-body">
<div class="date-row">
<span class="date-label">Date Published</span>
<input type="date" class="date-input" value="${ep.datePublished}"
onchange="setDate('${ep.id}', this.value)"
onclick="event.stopPropagation()" />
${ep.datePublished ? `<span style="font-size:12px;color:var(--warm-gray);font-style:italic;">${formatDate(ep.datePublished)}</span>` : ''}
</div>
<div class="phase-header phase-pre">Pre-Publish</div>
<ul class="task-list">${preTasks}</ul>
<div class="phase-header phase-post" style="margin-top:10px;">Post-Publish</div>
<ul class="task-list">${postTasks}</ul>
<div class="card-footer">${footer}</div>
</div>`;
}
return `
<div class="card${allDone ? " complete" : ""}">
<div class="card-header" onclick="toggleExpand('${ep.id}')">
<span class="series-tag tag-${ep.series}">${ep.series}</span>
<span class="ep-num">Ep ${ep.number}</span>
<span class="ep-label">${ep.series} — Episode ${ep.number}</span>
${dateDisplay ? `<span class="pub-date-pill">${dateDisplay}</span>` : ""}
<span class="task-count">${doneCount}/${total}</span>
<div class="mini-bar"><div class="mini-fill" style="width:${pct}%"></div></div>
<span class="chevron${ep.expanded ? " open" : ""}">▼</span>
</div>
${body}
</div>`;
}
function renderTask(epId, t, done) {
return `
<li class="task-item${done ? " done" : ""}" onclick="toggleTask('${epId}','${t.id}')">
<div class="check-box">
<svg class="check-svg" width="10" height="8" viewBox="0 0 10 8" fill="none">
<path d="M1 4L3.5 6.5L9 1" stroke="#0d0d0a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<span class="step-num">${t.step}</span>
<div class="task-body">
<div class="task-name">${t.label}</div>
<div class="task-hint">${t.desc}</div>
</div>
</li>`;
}
function updateStats() {
const totalDone = episodes.reduce((a, e) => a + ALL_TASK_IDS.filter(id => e.tasks[id]).length, 0);
const totalTasks = episodes.length * ALL_TASK_IDS.length;
const pct = Math.round((totalDone / totalTasks) * 100);
document.getElementById("overallPct").textContent = pct + "%";
document.getElementById("overallBar").style.width = pct + "%";
document.getElementById("titusDone").textContent =
episodes.filter(e => e.series === "Titus" && ALL_TASK_IDS.every(id => e.tasks[id])).length;
document.getElementById("colossDone").textContent =
episodes.filter(e => e.series === "Colossians" && ALL_TASK_IDS.every(id => e.tasks[id])).length;
}
loadFromServer();
</script>
</body>
</html>
-43
View File
@@ -1,43 +0,0 @@
const express = require("express");
const fs = require("fs");
const path = require("path");
const app = express();
const PORT = process.env.PORT || 3000;
const DATA_FILE = process.env.DATA_FILE || "/data/progress.json";
app.use(express.json());
app.use(express.static(path.join(__dirname, "public")));
// Ensure data directory and file exist
function ensureDataFile() {
const dir = path.dirname(DATA_FILE);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
if (!fs.existsSync(DATA_FILE)) fs.writeFileSync(DATA_FILE, JSON.stringify({}));
}
// GET progress
app.get("/api/progress", (req, res) => {
try {
ensureDataFile();
const data = JSON.parse(fs.readFileSync(DATA_FILE, "utf8"));
res.json(data);
} catch (err) {
console.error("Read error:", err);
res.status(500).json({ error: "Failed to read progress" });
}
});
// POST progress (full state)
app.post("/api/progress", (req, res) => {
try {
ensureDataFile();
fs.writeFileSync(DATA_FILE, JSON.stringify(req.body, null, 2));
res.json({ ok: true });
} catch (err) {
console.error("Write error:", err);
res.status(500).json({ error: "Failed to save progress" });
}
});
app.listen(PORT, () => console.log(`Podcast checklist running on port ${PORT}`));
Binary file not shown.
File diff suppressed because it is too large. Load diff
+148
View File
@@ -0,0 +1,148 @@
// Generates the /salvation audio from the narration files in scripts/salvation/ using ElevenLabs.
// There are three tracks: the case, then the listener chooses Guilty or Not guilty.
//
// node --env-file=.env scripts/generate-salvation-audio.mjs --list-voices # find your voice id
// node --env-file=.env scripts/generate-salvation-audio.mjs --sample # ~40 seconds, to test the voice (cheap)
// node --env-file=.env scripts/generate-salvation-audio.mjs # generate all three MP3s
// node --env-file=.env scripts/generate-salvation-audio.mjs --only=guilty # just one: case | guilty | not-guilty
//
// Needs, in your local .env (never commit it):
// ELEVENLABS_API_KEY=...
// ELEVENLABS_VOICE_ID=... (from --list-voices, or your cloned voice)
// Optional:
// ELEVENLABS_MODEL=eleven_multilingual_v2
// ELEVENLABS_STABILITY=0.5 (lower = more expressive, higher = steadier)
// ELEVENLABS_SPEED=1
//
// Output goes to audio-out/ (git-ignored). Upload the MP3s in Admin → Asset Manager, then paste their
// URLs into Admin → Gospel Page.
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const API = 'https://api.elevenlabs.io/v1'
const apiKey = process.env.ELEVENLABS_API_KEY
const voiceId = process.env.ELEVENLABS_VOICE_ID
const model = process.env.ELEVENLABS_MODEL || 'eleven_multilingual_v2'
const MAX_CHARS = 4500
let totalCost = 0
function fail(message) {
console.error(`\n${message}\n`)
process.exit(1)
}
if (!apiKey) fail('ELEVENLABS_API_KEY is not set. Add it to your local .env file (not to chat or to git).')
async function listVoices() {
const res = await fetch(`${API}/voices`, { headers: { 'xi-api-key': apiKey } })
if (!res.ok) fail(`Could not list voices (${res.status}). Check the API key.`)
const { voices = [] } = await res.json()
console.log('\nYour voices (name: voice id)\n')
for (const v of voices) console.log(` ${v.name}${v.category ? ` [${v.category}]` : ''}: ${v.voice_id}`)
console.log('\nPut the one you want in .env as ELEVENLABS_VOICE_ID=...\n')
}
// Split on paragraph boundaries so each request stays under MAX_CHARS and never cuts a sentence.
function chunkText(text) {
const paragraphs = text.split(/\n{2,}/).map(p => p.trim()).filter(Boolean)
const chunks = []
let current = ''
for (const p of paragraphs) {
if (current && current.length + p.length + 2 > MAX_CHARS) { chunks.push(current); current = '' }
current = current ? `${current}\n\n${p}` : p
}
if (current) chunks.push(current)
return chunks
}
async function speak(text, previous, next) {
const res = await fetch(`${API}/text-to-speech/${voiceId}?output_format=mp3_44100_128`, {
method: 'POST',
headers: { 'xi-api-key': apiKey, 'Content-Type': 'application/json', Accept: 'audio/mpeg' },
body: JSON.stringify({
text,
model_id: model,
voice_settings: {
stability: Number(process.env.ELEVENLABS_STABILITY ?? 0.5),
similarity_boost: 0.75,
style: 0,
use_speaker_boost: true,
speed: Number(process.env.ELEVENLABS_SPEED ?? 1),
},
...(previous ? { previous_text: previous.slice(-600) } : {}),
...(next ? { next_text: next.slice(0, 600) } : {}),
}),
})
if (!res.ok) {
const detail = await res.text().catch(() => '')
fail(`ElevenLabs returned ${res.status}.\n${detail.slice(0, 400)}`)
}
const cost = res.headers.get('character-cost')
if (cost) totalCost += Number(cost) || 0
return Buffer.from(await res.arrayBuffer())
}
const TRACKS = [
{ id: 'case', file: 'narration-1-case.txt', out: 'salvation-case.mp3' },
{ id: 'guilty', file: 'narration-2-guilty.txt', out: 'salvation-guilty.mp3' },
{ id: 'not-guilty', file: 'narration-3-not-guilty.txt', out: 'salvation-not-guilty.mp3' },
]
async function renderTrack(text, outPath) {
const chunks = chunkText(text)
const parts = []
for (let i = 0; i < chunks.length; i += 1) {
process.stdout.write(` part ${i + 1}/${chunks.length}… `)
parts.push(await speak(chunks[i], chunks[i - 1], chunks[i + 1]))
console.log('done')
}
await writeFile(outPath, Buffer.concat(parts))
}
async function main() {
if (process.argv.includes('--list-voices')) { await listVoices(); return }
if (!voiceId) fail('ELEVENLABS_VOICE_ID is not set. Run with --list-voices to find it, then add it to .env.')
const outDir = path.join(ROOT, 'audio-out')
await mkdir(outDir, { recursive: true })
if (process.argv.includes('--sample')) {
const full = (await readFile(path.join(ROOT, 'scripts/salvation', TRACKS[0].file), 'utf8')).trim()
// Whole paragraphs only, so the sample never stops mid-sentence (about 700 characters).
let sample = ''
for (const para of full.split(/\n{2,}/)) {
if (sample && sample.length + para.length > 750) break
sample = sample ? `${sample}\n\n${para}` : para
}
// Don't end on a lone heading like "The Accuser."
const lastBreak = sample.lastIndexOf('\n\n')
if (lastBreak > 0 && sample.length - lastBreak < 40) sample = sample.slice(0, lastBreak)
console.log(`\nSample: ${sample.length} characters (model ${model}).\n`)
const out = path.join(outDir, 'salvation-sample.mp3')
await renderTrack(sample, out)
if (totalCost) console.log(`\nElevenLabs reported ${totalCost.toLocaleString()} credits used.`)
console.log(`\nSaved ${out}\nIf the voice sounds right, run again without --sample.\n`)
return
}
const only = (process.argv.find(a => a.startsWith('--only=')) || '').split('=')[1]
const tracks = only ? TRACKS.filter(t => t.id === only) : TRACKS
if (!tracks.length) fail(`Unknown --only value "${only}". Use: case, guilty or not-guilty.`)
const texts = []
for (const t of tracks) texts.push((await readFile(path.join(ROOT, 'scripts/salvation', t.file), 'utf8')).trim())
const chars = texts.reduce((n, t) => n + t.length, 0)
console.log(`\n${tracks.length} track(s), ${chars.toLocaleString()} characters in total (model ${model}).`)
console.log('This uses about that many ElevenLabs credits.\n')
for (const [i, t] of tracks.entries()) {
console.log(` ${t.id} (${texts[i].length.toLocaleString()} characters)`)
await renderTrack(texts[i], path.join(outDir, t.out))
}
if (totalCost) console.log(`\nElevenLabs reported ${totalCost.toLocaleString()} credits used.`)
console.log(`\nSaved to ${outDir}:\n${tracks.map(t => ` ${t.out}`).join('\n')}\nListen to each one before you upload it. Check the Scripture names and references.\n`)
}
main().catch(err => fail(err?.message ?? String(err)))
+71
View File
@@ -0,0 +1,71 @@
The Gospel, in a Courtroom.
Imagine a courtroom. Not a courtroom on earth, but the highest court there is: the Court of Heaven. God is the Judge. Satan is the accuser. The charges are your sins, and you are the one on trial. But before the verdict is read, a Substitute has stepped forward to take your place. Let me walk you through the case.
The Judge.
God is perfectly just. He will not overlook wrongdoing. Nothing is hidden from him, and he judges rightly. In the book of Genesis, chapter eighteen, verse twenty-five, Scripture asks: "Will not the Judge of all the earth do right?" And in Acts, chapter seventeen, verse thirty-one: "For He has set a day when He will judge the world with justice by the Man He has appointed."
The Accuser.
Every courtroom has an accuser, and ours is Satan. His very name means "adversary." Scripture calls him the accuser of the brothers, who brings charges against us before God. He does not have to invent anything. He only has to point to what we have actually done. Revelation, chapter twelve, verse ten, calls him "the accuser of our brothers, who accuses them before our God day and night." And First Peter, chapter five, verse eight, warns: "Your adversary the devil prowls around like a roaring lion, looking for someone to devour."
The Charges.
So what are the charges? The charges are the sins you have committed.
Sin is breaking God's law. That includes the wrong things we knowingly do, the good things we fail to do, and the things we say and think in secret.
It also includes breaking his law without knowing it. Not knowing the law has never made anyone innocent. God's standard is written on every conscience, so that no one is without excuse. Even sin done unknowingly still leaves a person guilty.
And God does not grade on a curve. Stumbling at one point means you have broken the whole law. We have all sinned. That includes you, and it includes me.
First John, chapter three, verse four: "Everyone who practices sin practices lawlessness; indeed, sin is lawlessness." James, chapter two, verse ten: "For whoever keeps the whole law but stumbles at just one point is guilty of breaking all of it." Romans, chapter three, verse ten: "There is no one righteous, not even one." And Romans, chapter three, verse twenty-three: "For all have sinned and fall short of the glory of God."
The Verdict.
The evidence is clear, and there is no defense. We cannot argue our way out, and our good deeds cannot cancel our guilt. The sentence for sin is death: separation from the God who made us. Romans, chapter three, verse nineteen: "...so that every mouth may be silenced and the whole world held accountable to God." And Romans, chapter six, verse twenty-three: "For the wages of sin is death..."
But that is not the end of the story.
The Substitute.
Then God himself stepped forward to take your place.
Jesus is God. The eternal Son took on flesh and was born as a baby in Bethlehem, and he came for one purpose: to take the place of sinners like you and me. He did not come to be served. He came to give his life, and the cross was always where the road was heading.
He lived the perfect life you could not live. On the cross he took the sentence that belonged to you, and he rose again on the third day to prove the payment was accepted.
John, chapter one, verse fourteen: "The Word became flesh and dwelt among us." Matthew, chapter one, verse twenty-one: "She will bear a Son, and you are to name Him Jesus, because He will save His people from their sins." Mark, chapter ten, verse forty-five: "For even the Son of Man did not come to be served, but to serve, and to give His life as a ransom for many." First Peter, chapter three, verse eighteen: "For Christ also suffered once for sins, the righteous for the unrighteous, to bring you to God." Second Corinthians, chapter five, verse twenty-one: "God made Him who knew no sin to be sin for us, so that in Him we might become the righteousness of God." And Romans, chapter five, verse eight: "But God proves His love for us in this: While we were still sinners, Christ died for us."
The Pardon.
The pardon is a gift. It cannot be earned or bought. You receive it by turning from your sin and trusting Jesus alone. When you do, God declares you forgiven and counts you righteous because of what Christ has done.
Ephesians, chapter two, verses eight and nine: "For by grace you have been saved through faith, and this is not from yourselves, but it is the gift of God, not by works, so that no one can boast." And Romans, chapter ten, verse nine: "If you confess with your mouth that Jesus is Lord, and believe in your heart that God raised Him from the dead, you will be saved."
The Result.
Case closed. The accuser has nothing left to say. Our sins were real charges, but they have been answered, because the penalty has been paid in full by the Substitute. The one who trusts in Christ is not waiting nervously for a verdict. The verdict has already been given in their favor.
Romans, chapter eight, verse one: "Therefore, there is now no condemnation for those who are in Christ Jesus." And verses thirty-three and thirty-four: "Who can bring an accusation against those whom God has chosen? It is God who justifies. Who then can condemn? Christ Jesus, who died, more than that, was raised to life, is at the right hand of God, and He is interceding for us."
Before you plead.
Before you plead, read what Jesus did for you. Everything you have just heard tells you that the Substitute took your place. The record shows you how: the night he was arrested, the trials where he was declared innocent and condemned anyway, the long road to the cross, and the empty tomb on the third day.
Please do not skip it. It is the story of what Jesus did for you, told by four witnesses and woven into one account. Take your time and read it slowly. You can pause this audio at any time, read the record, and then come back and press play. When you are ready, continue.
How do you plead?
The court is not asking whether you are a good person. It is asking you to be honest about the charges, and to decide where you will stand: on your own record, or on what Jesus has done in your place.
You have two choices.
You can plead guilty. That means: I admit it. I have broken God's law, whether I knew it or not, and I cannot save myself. I am not asking the court to find me innocent. I am asking for the pardon Jesus paid for with his life. And here is what happens: the Judge declares you forgiven, and counts you righteous because of Christ. The accuser has nothing left to say.
Or you can plead not guilty. That means: I stand on my own record. I believe I have kept God's law well enough to be found innocent, and I do not need anyone to take my place. That plea has a cost, and I will explain it if you choose it.
Now look at the two buttons below the player. When you are ready, press the one that matches your plea, and I will walk you through what happens next.
Take a moment. Be honest with God, and honest with yourself.
+19
View File
@@ -0,0 +1,19 @@
You have pleaded guilty. That is the beginning of the good news.
You are not earning anything by saying this. You are receiving a gift. Romans, chapter six, verse twenty-three: "For the wages of sin is death, but the gift of God is eternal life in Christ Jesus our Lord." Here is what to do next.
First, tell God. Words do not save you. Trusting Jesus does. But you can say it to him in your own words, or you can pray this prayer. If you mean it, pray it out loud with me.
"Lord God, I plead guilty. I have sinned against you, and I cannot save myself. I believe Jesus Christ took my place on the cross, died for my sins, and rose again. I turn from my sin, and I trust him alone. Thank you for the pardon I could never earn. Make me new, and help me follow you. Amen."
Second, believe it is finished. If you meant that prayer, God has forgiven you. Romans, chapter eight, verse one: "Therefore, there is now no condemnation for those who are in Christ Jesus." Your feelings may go up and down, but the verdict does not change.
Third, tell someone. Tell a Christian friend or family member what you decided.
Fourth, start reading. Begin with the Gospel of John, then keep going through the New Testament, and talk to God every day.
Fifth, find a church. Join a Bible-teaching church where you can grow, and be baptized as a public sign of your faith. Acts, chapter two, verse thirty-eight.
And if you prayed that prayer and meant it, we would love to rejoice with you. Press the button below that says, Let us know. You can leave your name and your email, and someone from our team will reach out to you personally. It is completely optional.
Welcome to the family of God.
@@ -0,0 +1,23 @@
You have pleaded not guilty. Please be sure you understand what that means.
Pleading not guilty is not just saying, "I am not a bad person." It is telling the Judge, "I have kept your law perfectly, and I need no one to take my place."
God's standard is not "better than most people." It is perfection. Matthew, chapter five, verse forty-eight: "Be perfect, therefore, as your heavenly Father is perfect." And breaking that standard at even one point makes a person guilty of breaking all of it. James, chapter two, verse ten: "For whoever keeps the whole law but stumbles at just one point is guilty of breaking all of it." That includes the things done without knowing they were wrong.
So test your plea honestly.
Have you ever told a lie, even a small one?
Have you ever taken something that was not yours?
Have you ever used God's name carelessly, or put something else ahead of him: money, a person, yourself?
Have you ever hated someone in your heart, or looked at someone with lust? Jesus said those count too. Matthew, chapter five.
If the answer to any of those is yes, then by God's own standard, the plea does not hold. First John, chapter one, verse eight: "If we say that we have no sin, we are deceiving ourselves, and the truth is not in us."
And here is what a not guilty plea costs. It means standing before the Judge on your own record, with no Substitute. The sentence for sin stands. Romans, chapter six, verse twenty-three. But that is not what God wants for you. He says he takes no pleasure in anyone's death. Ezekiel, chapter thirty-three, verse eleven. And the offer of pardon is open right now. Second Corinthians, chapter six, verse two: "Now is the day of salvation."
If you are not sure, that is okay. Questions and doubts are fine. Asking honestly is not the same as refusing.
If you would like to change your plea, press the button below that says, Change my plea to guilty. You can also read the record of what Jesus did for you, or ask us a question. Whatever you decide, thank you for listening honestly. The offer of the pardon is still open.
+158 -20
View File
@@ -21,32 +21,39 @@ import {
loadDownloadCountsFromDisk, loadDownloadCountsFromDisk,
loadQrCodesFromDisk, loadQrCodesFromDisk,
loadEpisodePlaysFromDisk, loadEpisodePlaysFromDisk,
loadPodcastChecklistFromDisk,
loadAnalyticsEventsFromDisk, loadAnalyticsEventsFromDisk,
loadEmailSettingsFromDisk, loadEmailSettingsFromDisk,
loadCalendarEventsFromDisk,
loadAuditLogFromDisk, loadAuditLogFromDisk,
loadWebhooksFromDisk, loadWebhooksFromDisk,
loadStudySessionsFromDisk,
queueStudySessionsWrite,
loadNewsletterIssuesFromDisk,
loadNuggetsTagsFromDisk,
createBackupSnapshot, createBackupSnapshot,
refreshContentCaches, refreshContentCaches,
queueHitStatsWrite, queueHitStatsWrite,
} from './server/data.js' } from './server/data.js'
import { logResendEmailAlignmentWarnings, sendStudyReminderEmail, sendStudyReengagementEmail } from './server/email.js' import { logResendEmailAlignmentWarnings, sendStudyReminderEmail, sendStudyReengagementEmail, sendStudyWeeklyDigestEmail } from './server/email.js'
import { import {
detectBot, detectBot,
sanitizeUserAgent, sanitizeUserAgent,
shouldCountHit, shouldCountHit,
recordHit, recordHit,
isKnownSitePath,
scheduleStudyReminders, scheduleStudyReminders,
scheduleReengagementEmails, scheduleReengagementEmails,
scheduleWeeklyDigestEmails,
backfillCompletedStudies,
backfillReminderDefault,
} from './server/study-helpers.js' } from './server/study-helpers.js'
import { recordVisitor } from './server/routes/analytics.js' import { recordVisitor } from './server/routes/analytics.js'
// Route registrars // Route registrars
import { register as registerAdminAuth } from './server/routes/admin-auth.js' import { register as registerAdminAuth } from './server/routes/admin-auth.js'
import { register as registerAdminContent } from './server/routes/admin-content.js' import { register as registerAdminContent, runScheduledSeriesTransition } from './server/routes/admin-content.js'
import { register as registerAdminAssets } from './server/routes/admin-assets.js' import { register as registerAdminAssets } from './server/routes/admin-assets.js'
import { register as registerAdminBackup } from './server/routes/admin-backup.js' import { register as registerAdminBackup } from './server/routes/admin-backup.js'
import { register as registerAdminDashboard } from './server/routes/admin-dashboard.js'
import { register as registerStudyAuth } from './server/routes/study-auth.js' import { register as registerStudyAuth } from './server/routes/study-auth.js'
import { register as registerStudyData } from './server/routes/study-data.js' import { register as registerStudyData } from './server/routes/study-data.js'
import { register as registerStudyAccount } from './server/routes/study-account.js' import { register as registerStudyAccount } from './server/routes/study-account.js'
@@ -54,18 +61,28 @@ import { register as registerStudyComments } from './server/routes/study-comment
import { register as registerStudyCertificate } from './server/routes/study-certificate.js' import { register as registerStudyCertificate } from './server/routes/study-certificate.js'
import { register as registerEpisodeScripts } from './server/routes/episode-scripts.js' import { register as registerEpisodeScripts } from './server/routes/episode-scripts.js'
import { register as registerContact } from './server/routes/contact.js' import { register as registerContact } from './server/routes/contact.js'
import { register as registerCalendarEvents } from './server/routes/calendar-events.js'
import { startReminderScheduler } from './server/reminder.js'
import { register as registerInboundEmail } from './server/routes/inbound-email.js' import { register as registerInboundEmail } from './server/routes/inbound-email.js'
import { register as registerQuestions } from './server/routes/questions.js' import { register as registerQuestions } from './server/routes/questions.js'
import { register as registerAnalytics } from './server/routes/analytics.js' import { register as registerAnalytics } from './server/routes/analytics.js'
import { register as registerDownloads } from './server/routes/downloads.js' import { register as registerDownloads } from './server/routes/downloads.js'
import { register as registerEpisodes } from './server/routes/episodes.js' import { register as registerEpisodes } from './server/routes/episodes.js'
import { register as registerNuggets } from './server/routes/nuggets.js'
import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.js'
import { register as registerNuggetsWorkflow } from './server/routes/nuggets-workflow.js'
import { register as registerPodcastWorkflow } from './server/routes/podcast-workflow.js'
import { register as registerSeasonCheck } from './server/season-check.js'
import { warmFeedSnapshot } from './server/feed-release.js'
import { preferIpv4ForSeafile } from './server/seafile-dns.js'
import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js'
import { register as registerFeed } from './server/routes/feed.js' import { register as registerFeed } from './server/routes/feed.js'
import { register as registerQrCodes } from './server/routes/qr-codes.js' import { register as registerQrCodes } from './server/routes/qr-codes.js'
import { register as registerBiblePassage } from './server/routes/bible-passage.js' import { register as registerBiblePassage } from './server/routes/bible-passage.js'
import { register as registerNewsletter } from './server/routes/newsletter.js'
import { register as registerSalvation } from './server/routes/salvation.js'
import { register as registerPublic } from './server/routes/public.js' import { register as registerPublic } from './server/routes/public.js'
preferIpv4ForSeafile()
const app = express() const app = express()
app.use(express.json({ limit: '10mb' })) app.use(express.json({ limit: '10mb' }))
const trustProxyHops = Number(process.env.TRUST_PROXY_HOPS ?? 1) const trustProxyHops = Number(process.env.TRUST_PROXY_HOPS ?? 1)
@@ -75,16 +92,20 @@ app.use((_req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff') res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('X-Frame-Options', 'SAMEORIGIN') res.setHeader('X-Frame-Options', 'SAMEORIGIN')
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin') res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()')
if (process.env.NODE_ENV === 'production') {
res.setHeader('Strict-Transport-Security', 'max-age=31536000')
}
res.setHeader( res.setHeader(
'Content-Security-Policy', 'Content-Security-Policy',
[ [
"default-src 'self'", "default-src 'self'",
"script-src 'self' 'unsafe-inline' https://www.truthforlife.org https://ajax.googleapis.com", "script-src 'self' 'unsafe-inline' https://www.truthforlife.org https://ajax.googleapis.com https://storage.ko-fi.com https://static.cloudflareinsights.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://www.truthforlife.org",
"font-src 'self' https://fonts.gstatic.com", "font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: https:", "img-src 'self' data: https:",
"media-src 'self' https:", "media-src 'self' https:",
"frame-src https:", "frame-src 'self' https:",
"connect-src 'self' https:", "connect-src 'self' https:",
].join('; '), ].join('; '),
) )
@@ -96,6 +117,7 @@ registerAdminAuth(app)
registerAdminContent(app) registerAdminContent(app)
registerAdminAssets(app) registerAdminAssets(app)
registerAdminBackup(app) registerAdminBackup(app)
registerAdminDashboard(app)
registerStudyAuth(app) registerStudyAuth(app)
registerStudyData(app) registerStudyData(app)
registerStudyAccount(app) registerStudyAccount(app)
@@ -103,27 +125,36 @@ registerStudyComments(app)
registerStudyCertificate(app) registerStudyCertificate(app)
registerEpisodeScripts(app) registerEpisodeScripts(app)
registerContact(app) registerContact(app)
registerCalendarEvents(app)
registerInboundEmail(app) registerInboundEmail(app)
registerQuestions(app) registerQuestions(app)
registerAnalytics(app) registerAnalytics(app)
registerDownloads(app) registerDownloads(app)
registerEpisodes(app) registerEpisodes(app)
registerNuggets(app)
registerNuggetsHostUpload(app)
registerNuggetsWorkflow(app)
registerPodcastWorkflow(app)
registerSeasonCheck(app)
registerFeed(app) registerFeed(app)
registerQrCodes(app) registerQrCodes(app)
registerBiblePassage(app) registerBiblePassage(app)
registerNewsletter(app)
registerSalvation(app)
// Hit-counting middleware (must come before public routes) // Hit-counting middleware (must come before public routes)
app.use((req, res, next) => { app.use((req, res, next) => {
if (shouldCountHit(req)) { if (shouldCountHit(req)) {
const ua = sanitizeUserAgent(req.get('user-agent')) const ua = sanitizeUserAgent(req.get('user-agent'))
const botDetection = detectBot(ua) const knownPath = isKnownSitePath(req.path)
const botDetection = knownPath ? detectBot(ua) : { isBot: true, reason: 'unknown-path' }
recordHit(req.path, botDetection.isBot, botDetection.reason) recordHit(req.path, botDetection.isBot, botDetection.reason)
queueHitStatsWrite() queueHitStatsWrite()
if (hasVisitorConsent(req) && !botDetection.isBot && !isValidAdminSession(req)) { if (hasVisitorConsent(req) && !botDetection.isBot && !isValidAdminSession(req)) {
recordVisitor(req, res).catch(err => { try {
recordVisitor(req, res)
} catch (err) {
console.error('[visitor-stats] failed to record visitor:', err) console.error('[visitor-stats] failed to record visitor:', err)
}) }
} }
} }
next() next()
@@ -132,6 +163,18 @@ app.use((req, res, next) => {
// Public routes (robots, sitemap, static, SPA fallback) // Public routes (robots, sitemap, static, SPA fallback)
registerPublic(app) registerPublic(app)
// JSON error responses for API routes instead of Express's default HTML page.
app.use((err, req, res, _next) => {
if (res.headersSent) return
const status = Number.isInteger(err?.status) && err.status >= 400 && err.status < 600 ? err.status : 500
if (status >= 500) console.error('[error]', req.method, req.path, err)
if (req.path.startsWith('/api/')) {
res.status(status).json({ message: status >= 500 ? 'Internal server error.' : String(err?.message ?? 'Request failed.') })
return
}
res.status(status).type('text/plain').send(status >= 500 ? 'Internal server error.' : 'Bad request.')
})
const PORT = Number(process.env.PORT ?? 4173) const PORT = Number(process.env.PORT ?? 4173)
Promise.all([ Promise.all([
loadHitStatsFromDisk(), loadHitStatsFromDisk(),
@@ -151,31 +194,45 @@ Promise.all([
loadDownloadCountsFromDisk(), loadDownloadCountsFromDisk(),
loadQrCodesFromDisk(), loadQrCodesFromDisk(),
loadEpisodePlaysFromDisk(), loadEpisodePlaysFromDisk(),
loadPodcastChecklistFromDisk(),
loadAnalyticsEventsFromDisk(), loadAnalyticsEventsFromDisk(),
loadEmailSettingsFromDisk(), loadEmailSettingsFromDisk(),
loadCalendarEventsFromDisk(),
loadAuditLogFromDisk(), loadAuditLogFromDisk(),
loadWebhooksFromDisk(), loadWebhooksFromDisk(),
loadStudySessionsFromDisk(),
loadNewsletterIssuesFromDisk(),
loadNuggetsTagsFromDisk(),
refreshContentCaches(), refreshContentCaches(),
]) ])
.catch(err => { .catch(err => {
console.error('[stats] failed to load persisted stats:', err) console.error('[stats] failed to load persisted stats:', err)
}) })
.then(() => backfillCompletedStudies())
.then(updated => { if (updated > 0) console.log(`[study] marked ${updated} finished studies as completed`) })
.catch(err => {
console.error('[study] completed-study backfill failed:', err)
})
.then(() => backfillReminderDefault())
.then(updated => { if (updated > 0) console.log(`[study] turned lesson reminders on for ${updated} existing accounts`) })
.catch(err => {
console.error('[study] reminder-default backfill failed:', err)
})
.finally(() => { .finally(() => {
createBackupSnapshot('startup').catch(() => {}) createBackupSnapshot('startup').catch(() => {})
setInterval(() => { setInterval(() => {
createBackupSnapshot('scheduled').catch(() => {}) createBackupSnapshot('scheduled').catch(() => {})
}, BACKUP_INTERVAL_MS) }, BACKUP_INTERVAL_MS)
startReminderScheduler() startNuggetsSeafileWatcher()
warmFeedSnapshot()
// Purge expired study sessions every hour // Purge expired study sessions every hour
setInterval(() => { setInterval(() => {
const now = Date.now() const now = Date.now()
let pruned = false
for (const [token, session] of state.studySessions) { for (const [token, session] of state.studySessions) {
if (session.expiresAt <= now) state.studySessions.delete(token) if (session.expiresAt <= now) { state.studySessions.delete(token); pruned = true }
} }
if (pruned) queueStudySessionsWrite()
}, 60 * 60 * 1000) }, 60 * 60 * 1000)
// Send study reminder emails for newly released lessons every hour // Send study reminder emails for newly released lessons every hour
@@ -184,7 +241,9 @@ Promise.all([
console.error('[study-reminders] failed to schedule reminders:', err) console.error('[study-reminders] failed to schedule reminders:', err)
}) })
}, 60 * 60 * 1000) }, 60 * 60 * 1000)
void scheduleStudyReminders(sendStudyReminderEmail) scheduleStudyReminders(sendStudyReminderEmail).catch(err => {
console.error('[study-reminders] failed to schedule reminders:', err)
})
// Send re-engagement emails to inactive study users every hour // Send re-engagement emails to inactive study users every hour
setInterval(() => { setInterval(() => {
@@ -192,10 +251,89 @@ Promise.all([
console.error('[study-reengagement] failed to schedule emails:', err) console.error('[study-reengagement] failed to schedule emails:', err)
}) })
}, 60 * 60 * 1000) }, 60 * 60 * 1000)
void scheduleReengagementEmails(sendStudyReengagementEmail) scheduleReengagementEmails(sendStudyReengagementEmail).catch(err => {
console.error('[study-reengagement] failed to schedule emails:', err)
})
app.listen(PORT, () => { // Send weekly lesson digest emails every hour (only actually fires every 7 days per user)
setInterval(() => {
scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail).catch(err => {
console.error('[study-digest] failed to schedule weekly digest:', err)
})
}, 60 * 60 * 1000)
scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail).catch(err => {
console.error('[study-digest] failed to schedule weekly digest:', err)
})
// Check for a scheduled series transition every minute
setInterval(() => {
runScheduledSeriesTransition().catch(err => {
console.error('[series-scheduler] error:', err)
})
}, 60 * 1000)
runScheduledSeriesTransition().catch(err => {
console.error('[series-scheduler] error:', err)
})
const server = app.listen(PORT, () => {
logResendEmailAlignmentWarnings() logResendEmailAlignmentWarnings()
console.log(`Siteforge listening on http://localhost:${PORT}`) console.log(`Siteforge listening on http://localhost:${PORT}`)
}) })
// On every deploy the running container gets SIGTERM'd and replaced.
// All the queueXWrite() helpers in data.js debounce disk writes onto
// per-resource promise chains — without draining those here, a redeploy
// can kill the process mid-write and silently lose the last few
// analytics events (or any other pending write) of that container's life.
function collectPendingWrites() {
return [
state.hitStatsWritePromise,
state.visitorStatsWritePromise,
state.contactSubmissionsWritePromise,
state.questionsWritePromise,
state.draftQuestionsWritePromise,
state.replyTemplatesWritePromise,
state.replyHistoryWritePromise,
state.studyUsersWritePromise,
state.studyCommunityWritePromise,
state.studyRemindersWritePromise,
state.studyCommentsWritePromise,
state.studyCertificatesWritePromise,
state.episodeScriptsWritePromise,
state.downloadCountsWritePromise,
state.episodePlaysWritePromise,
state.analyticsEventsWritePromise,
state.qrCodesWritePromise,
state.emailSettingsWritePromise,
state.auditLogWritePromise,
state.webhooksWritePromise,
state.studySessionsWritePromise,
...state.studyNotesWriteQueues.values(),
...state.studyProgressWriteQueues.values(),
]
}
let shuttingDown = false
async function gracefulShutdown(signal) {
if (shuttingDown) return
shuttingDown = true
console.log(`[shutdown] ${signal} received — closing server and flushing pending writes...`)
server.close()
const forceExit = setTimeout(() => {
console.error('[shutdown] timed out waiting for pending writes; exiting anyway')
process.exit(1)
}, 10_000)
try {
await Promise.all(collectPendingWrites())
console.log('[shutdown] all pending writes flushed')
} catch (err) {
console.error('[shutdown] error while flushing writes:', err)
} finally {
clearTimeout(forceExit)
process.exit(0)
}
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
process.on('SIGINT', () => gracefulShutdown('SIGINT'))
}) })
+17
View File
@@ -0,0 +1,17 @@
import { writeFile as fsWriteFile, rename, unlink } from 'node:fs/promises'
import { randomBytes } from 'node:crypto'
// Drop-in replacement for fs.promises.writeFile that never leaves a truncated
// file behind: the data goes to a sibling temp file which is then renamed over
// the target (atomic on the same filesystem). A crash mid-write leaves the
// previous version intact instead of a half-written JSON file.
export async function writeFile(file, data, options) {
const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
try {
await fsWriteFile(tmp, data, options)
await rename(tmp, file)
} catch (err) {
await unlink(tmp).catch(() => {})
throw err
}
}
+44 -11
View File
@@ -1,5 +1,7 @@
import { createHash, randomUUID, timingSafeEqual, createHmac, randomFillSync } from 'node:crypto' import { createHash, randomUUID, timingSafeEqual, createHmac, randomFillSync } from 'node:crypto'
import { readFile, writeFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import { writeFile } from './atomic-write.js'
import { readFileSync } from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { parseCookies, cookieSecureFlag } from './helpers.js' import { parseCookies, cookieSecureFlag } from './helpers.js'
import { DATA_DIR } from './paths.js' import { DATA_DIR } from './paths.js'
@@ -11,16 +13,44 @@ const TOTP_SECRET_FILE = path.join(DATA_DIR, 'totp-secret.json')
const TOTP_PENDING_TTL_MS = 5 * 60 * 1000 const TOTP_PENDING_TTL_MS = 5 * 60 * 1000
const totpPendingSessions = new Map() const totpPendingSessions = new Map()
const ADMIN_SESSION_COOKIE = 'vbn_admin_session' export const ADMIN_SESSION_COOKIE = 'vbn_admin_session'
const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000 const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
const ADMIN_SESSION_ABSOLUTE_TTL_MS = 30 * 24 * 60 * 60 * 1000 const ADMIN_SESSION_ABSOLUTE_TTL_MS = 30 * 24 * 60 * 60 * 1000
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
const adminSessions = new Map() const adminSessions = new Map()
function cookieFlags() { // Sessions survive restarts/deploys. Only a SHA-256 of each token is written to
return cookieSecureFlag() // disk, so the file alone cannot be used to hijack a session. Sliding-expiry
// refreshes are not persisted; a restart reverts to the last persisted expiry.
const ADMIN_SESSIONS_FILE = path.join(DATA_DIR, 'admin-sessions.json')
let adminSessionsWrite = Promise.resolve()
function persistAdminSessions() {
const sessions = []
for (const [tokenHash, session] of adminSessions) {
if (session && typeof session === 'object') sessions.push({ tokenHash, ...session })
}
adminSessionsWrite = adminSessionsWrite
.then(() => writeFile(ADMIN_SESSIONS_FILE, JSON.stringify({ sessions }), { encoding: 'utf8', mode: 0o600 }))
.catch(err => { console.error('[admin-sessions] write failed:', err) })
} }
// Loaded sessions are keyed by token hash; lookups hash the cookie value, so the
// in-memory map is keyed by hash throughout.
function adminSessionKey(token) {
return sha256(token)
}
try {
const parsed = JSON.parse(readFileSync(ADMIN_SESSIONS_FILE, 'utf8'))
const now = Date.now()
for (const e of Array.isArray(parsed?.sessions) ? parsed.sessions : []) {
if (typeof e?.tokenHash !== 'string' || typeof e.expiresAt !== 'number' || typeof e.absoluteExpiresAt !== 'number') continue
if (e.expiresAt <= now || e.absoluteExpiresAt <= now) continue
adminSessions.set(e.tokenHash, { expiresAt: e.expiresAt, absoluteExpiresAt: e.absoluteExpiresAt })
}
} catch { /* no persisted sessions */ }
export function sha256(value) { export function sha256(value) {
return createHash('sha256').update(String(value)).digest('hex') return createHash('sha256').update(String(value)).digest('hex')
} }
@@ -217,13 +247,15 @@ export function consumePendingSession(token) {
export function createAdminSession() { export function createAdminSession() {
const token = randomUUID() const token = randomUUID()
const now = Date.now() const now = Date.now()
adminSessions.set(token, { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt: now + ADMIN_SESSION_ABSOLUTE_TTL_MS }) adminSessions.set(adminSessionKey(token), { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt: now + ADMIN_SESSION_ABSOLUTE_TTL_MS })
persistAdminSessions()
return token return token
} }
export function deleteAdminSession(token) { export function deleteAdminSession(token) {
if (token) { if (token) {
adminSessions.delete(token) adminSessions.delete(adminSessionKey(token))
persistAdminSessions()
} }
} }
@@ -235,7 +267,8 @@ export function isValidAdminSession(req) {
if (!sessionToken) return false if (!sessionToken) return false
const now = Date.now() const now = Date.now()
const session = adminSessions.get(sessionToken) const key = adminSessionKey(sessionToken)
const session = adminSessions.get(key)
if (!session) return false if (!session) return false
// Support legacy sessions stored as a plain number (expiresAt) // Support legacy sessions stored as a plain number (expiresAt)
@@ -243,25 +276,25 @@ export function isValidAdminSession(req) {
const absoluteExpiresAt = typeof session === 'object' ? session.absoluteExpiresAt : Infinity const absoluteExpiresAt = typeof session === 'object' ? session.absoluteExpiresAt : Infinity
if (expiresAt <= now || absoluteExpiresAt <= now) { if (expiresAt <= now || absoluteExpiresAt <= now) {
adminSessions.delete(sessionToken) adminSessions.delete(key)
return false return false
} }
adminSessions.set(sessionToken, { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt }) adminSessions.set(key, { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt })
return true return true
} }
export function setAdminSessionCookie(res, token) { export function setAdminSessionCookie(res, token) {
res.append( res.append(
'Set-Cookie', 'Set-Cookie',
`${ADMIN_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`, `${ADMIN_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
) )
} }
export function clearAdminSessionCookie(res) { export function clearAdminSessionCookie(res) {
res.append( res.append(
'Set-Cookie', 'Set-Cookie',
`${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`, `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
) )
} }
+36 -95
View File
@@ -5,6 +5,7 @@ import { validateAdminPasswordSetup } from './auth.js'
import { ROOT_DIR, DATA_DIR } from './paths.js' import { ROOT_DIR, DATA_DIR } from './paths.js'
export { ROOT_DIR, DATA_DIR } export { ROOT_DIR, DATA_DIR }
export { DEFAULT_SEO, DEFAULT_LEGAL, DEFAULT_REDIRECT_RULES, DEFAULT_PODCAST_FEATURED_LINKS, DEFAULT_PUBLISH_STATE } from './helpers.js'
function readPackageVersion() { function readPackageVersion() {
try { try {
@@ -46,7 +47,6 @@ export const STUDY_PROGRESS_DIR = path.join(DATA_DIR, 'study-progress')
export const STUDY_COMMUNITY_FILE = path.join(DATA_DIR, 'study-community.json') export const STUDY_COMMUNITY_FILE = path.join(DATA_DIR, 'study-community.json')
export const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json') export const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json')
export const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json') export const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json')
export const PODCAST_CHECKLIST_FILE = path.join(DATA_DIR, 'podcast-checklist.json')
export const BACKUP_DIR = path.join(DATA_DIR, 'backups') export const BACKUP_DIR = path.join(DATA_DIR, 'backups')
export const UPLOADS_DIR = path.join(DATA_DIR, 'uploads') export const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
export const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json') export const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json')
@@ -58,12 +58,41 @@ export const EPISODE_SCRIPTS_FILE = path.join(DATA_DIR, 'episode-scripts.json')
export const QR_CODES_FILE = path.join(DATA_DIR, 'qr-codes.json') export const QR_CODES_FILE = path.join(DATA_DIR, 'qr-codes.json')
export const EPISODE_PLAYS_FILE = path.join(DATA_DIR, 'episode-plays.json') export const EPISODE_PLAYS_FILE = path.join(DATA_DIR, 'episode-plays.json')
export const ANALYTICS_EVENTS_FILE = path.join(DATA_DIR, 'analytics-events.json') export const ANALYTICS_EVENTS_FILE = path.join(DATA_DIR, 'analytics-events.json')
export const NUGGETS_UPLOAD_HOSTS_FILE = path.join(DATA_DIR, 'nuggets-upload-hosts.json')
export const NUGGETS_SEAFILE_SEEN_FILE = path.join(DATA_DIR, 'nuggets-seafile-seen.json')
export const NUGGETS_UPLOAD_HISTORY_FILE = path.join(DATA_DIR, 'nuggets-upload-history.json')
export const NUGGETS_PORTAL_ANNOUNCEMENT_FILE = path.join(DATA_DIR, 'nuggets-portal-announcement.json')
export const NUGGETS_PORTAL_GUIDELINES_FILE = path.join(DATA_DIR, 'nuggets-portal-guidelines.json')
export const SEAFILE_NUGGETS_SHARE_URL = process.env.SEAFILE_NUGGETS_SHARE_URL ?? 'https://seafile.necloud.us/d/437ee6b8476d4b7e90a2/'
// Email address for Nuggets upload notifications (defaults to the admin inbox)
export const NUGGETS_NOTIFY_EMAIL = process.env.NUGGETS_NOTIFY_EMAIL ?? process.env.RESEND_TO ?? 'hello@versebyversewithnate.us'
export const SEAFILE_POLL_INTERVAL_MS = 15 * 60 * 1000 // 15 minutes
export const EMAIL_SETTINGS_FILE = path.join(DATA_DIR, 'email-settings.json') export const EMAIL_SETTINGS_FILE = path.join(DATA_DIR, 'email-settings.json')
export const CALENDAR_EVENTS_FILE = path.join(DATA_DIR, 'calendar-events.json')
export const AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.json') export const AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.json')
export const WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.json') export const WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.json')
export const STUDY_SESSIONS_FILE = path.join(DATA_DIR, 'study-sessions.json')
export const NEWSLETTER_ISSUES_FILE = path.join(DATA_DIR, 'newsletter-issues.json')
export const NUGGETS_TAGS_FILE = path.join(DATA_DIR, 'nuggets-tags.json')
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
// Seafile integration for Nuggets episode uploads
export const SEAFILE_URL = (process.env.SEAFILE_URL ?? '').replace(/\/$/, '')
export const SEAFILE_TOKEN = process.env.SEAFILE_TOKEN ?? ''
export const SEAFILE_NUGGETS_REPO_ID = process.env.SEAFILE_NUGGETS_REPO_ID ?? ''
export const SEAFILE_EPISODE_INFO_DIR = process.env.SEAFILE_EPISODE_INFO_DIR ?? '/Episode Info'
export const SEAFILE_EPISODE_UPLOAD_DIR = process.env.SEAFILE_EPISODE_UPLOAD_DIR ?? '/Episode Upload'
// Cloudflare R2 storage
export const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID ?? ''
export const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID ?? ''
export const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY ?? ''
export const R2_BUCKET = process.env.R2_BUCKET ?? ''
export const R2_PUBLIC_URL = (process.env.R2_PUBLIC_URL ?? '').replace(/\/$/, '')
export const HOST_SESSION_COOKIE = 'vbn_host_session'
export const HOST_SESSION_TTL_MS = 24 * 60 * 60 * 1000
export const DIST_DIR = path.join(ROOT_DIR, 'dist') export const DIST_DIR = path.join(ROOT_DIR, 'dist')
export const INDEX_FILE = path.join(DIST_DIR, 'index.html') export const INDEX_FILE = path.join(DIST_DIR, 'index.html')
export const DIST_IMAGES_DIR = path.join(DIST_DIR, 'images') export const DIST_IMAGES_DIR = path.join(DIST_DIR, 'images')
@@ -83,7 +112,6 @@ export const MAX_RECENT_VISITS = 1000
export const VISITOR_RETENTION_DAYS_DEFAULT = 180 export const VISITOR_RETENTION_DAYS_DEFAULT = 180
export const BACKUP_RETENTION_DAYS = 30 export const BACKUP_RETENTION_DAYS = 30
export const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000 export const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000
export const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
export const MAX_CONTACT_SUBMISSIONS = 5000 export const MAX_CONTACT_SUBMISSIONS = 5000
export const CONTACT_EMAIL_COOLDOWN_MS = Math.max(10 * 1000, Number(process.env.CONTACT_EMAIL_COOLDOWN_MS ?? 60 * 1000) || 60 * 1000) export const CONTACT_EMAIL_COOLDOWN_MS = Math.max(10 * 1000, Number(process.env.CONTACT_EMAIL_COOLDOWN_MS ?? 60 * 1000) || 60 * 1000)
@@ -94,9 +122,11 @@ export const MAX_STUDY_COMMENTS = 10000
export const MAX_STUDY_COMMENT_LENGTH = 2000 export const MAX_STUDY_COMMENT_LENGTH = 2000
export const MAX_STUDY_USERS = 5000 export const MAX_STUDY_USERS = 5000
export const MAX_STUDY_ENROLLMENTS_PER_USER = 100 export const MAX_STUDY_ENROLLMENTS_PER_USER = 100
export const MAX_STUDY_NOTES_PER_USER = 500 export const MAX_STUDY_NOTES_PER_USER = 3000
export const MAX_STUDY_NOTE_LENGTH = 12000 export const MAX_STUDY_NOTE_LENGTH = 200000
export const EMAIL_CHANGE_TOKEN_TTL_MS = 24 * 60 * 60 * 1000 export const EMAIL_CHANGE_TOKEN_TTL_MS = 24 * 60 * 60 * 1000
export const EMAIL_VERIFY_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000
export const PASSWORD_RESET_TOKEN_TTL_MS = 60 * 60 * 1000
export const STUDY_SESSION_COOKIE = 'vbn_study_session' export const STUDY_SESSION_COOKIE = 'vbn_study_session'
export const STUDY_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000 export const STUDY_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000
export const STUDY_TOTP_PENDING_TTL_MS = 5 * 60 * 1000 export const STUDY_TOTP_PENDING_TTL_MS = 5 * 60 * 1000
@@ -112,58 +142,6 @@ export const NATE_RESEND_FROM = 'Verse by Verse with Nate <nate@versebyversewith
// Addresses an admin may send a reply from — must stay in sync with the <select> options in src/AdminPage.tsx. // Addresses an admin may send a reply from — must stay in sync with the <select> options in src/AdminPage.tsx.
export const ADMIN_REPLY_FROM_OPTIONS = [DEFAULT_RESEND_FROM, NATE_RESEND_FROM] export const ADMIN_REPLY_FROM_OPTIONS = [DEFAULT_RESEND_FROM, NATE_RESEND_FROM]
export const DEFAULT_SEO = {
title: 'Verse by Verse with Nate',
description: 'Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.',
ogTitle: 'Verse by Verse with Nate',
ogDescription: 'A Journey Through Scripture - verse by verse, nugget by nugget.',
ogImage: '/images/podcast-art.jpeg',
canonicalUrl: 'https://versebyversewithnate.us/',
robotsPolicy: 'index,follow',
sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms'],
}
export const DEFAULT_LEGAL = {
privacyTitle: 'Privacy Policy',
privacyBody: [
'We respect your privacy and collect limited data to operate and improve this site.',
'If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.',
'Contact form details are used only to respond to your message and ministry communication requests.',
],
termsTitle: 'Terms',
termsBody: [
'Content on this site is for informational and ministry purposes.',
'External links are provided for convenience and are subject to third-party policies.',
'By using this site, you agree to lawful use and respectful communication.',
],
}
export const DEFAULT_PODCAST_FEATURED_LINKS = []
export const DEFAULT_PUBLISH_STATE = {
draftUpdatedAt: null,
publishedAt: null,
}
export const DEFAULT_REDIRECT_RULES = [
{
id: 'spotify',
path: '/spotify',
target: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl',
statusCode: 301,
},
{
id: 'apple',
path: '/apple',
target: 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate',
statusCode: 301,
},
{
id: 'amazon',
path: '/amazon',
target: 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate',
statusCode: 301,
},
]
export const DEFAULT_REPLY_TEMPLATES = [ export const DEFAULT_REPLY_TEMPLATES = [
{ {
id: 'thanks-for-reaching-out', id: 'thanks-for-reaching-out',
@@ -185,18 +163,6 @@ export const DEFAULT_REPLY_TEMPLATES = [
}, },
] ]
export const DEFAULT_PODCAST_CHECKLIST_TASKS = [
{ id: 'verify_script', label: 'Verify Script', phase: 'pre' },
{ id: 'read_script', label: 'Read Script', phase: 'pre' },
{ id: 'record', label: 'Record', phase: 'pre' },
{ id: 'mix', label: 'Mix', phase: 'pre' },
{ id: 'edit', label: 'Edit', phase: 'pre' },
{ id: 'video_script', label: 'Run Video Conversion Script', phase: 'pre' },
{ id: 'post_spotify', label: 'Post on Spotify', phase: 'pre' },
{ id: 'update_website', label: 'Update Website', phase: 'post' },
{ id: 'send_email', label: 'Send Email', phase: 'post' },
]
export const EMPTY_HIT_STATS = { export const EMPTY_HIT_STATS = {
totalHits: 0, totalHits: 0,
realHits: 0, realHits: 0,
@@ -206,6 +172,7 @@ export const EMPTY_HIT_STATS = {
byPath: {}, byPath: {},
byPathReal: {}, byPathReal: {},
byPathBot: {}, byPathBot: {},
byPathUnknown: {},
byDay: {}, byDay: {},
byDayReal: {}, byDayReal: {},
byDayBot: {}, byDayBot: {},
@@ -224,29 +191,3 @@ export const EMPTY_VISITOR_STATS = {
geoCacheByIp: {}, geoCacheByIp: {},
} }
// ── Podcast checklist helpers (no state dependency) ────────────────────────
function buildChecklistEpisode(series, number) {
const tasks = {}
for (const task of DEFAULT_PODCAST_CHECKLIST_TASKS) {
tasks[task.id] = false
}
return {
id: `${series.toLowerCase()}-${number}`,
series,
episodeNumber: number,
title: '',
datePublished: '',
expanded: false,
tasks,
}
}
export function buildDefaultPodcastChecklist() {
const titusEpisodes = [11, 12, 13, 14, 15].map(number => buildChecklistEpisode('Titus', number))
const colossiansEpisodes = Array.from({ length: 27 }, (_, index) => buildChecklistEpisode('Colossians', index + 1))
return {
tasks: DEFAULT_PODCAST_CHECKLIST_TASKS,
episodes: [...titusEpisodes, ...colossiansEpisodes],
}
}
+212 -151
View File
@@ -1,4 +1,7 @@
import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises' import { migrateScriptKeys } from './episode-script-keys.js'
import { mkdir, readFile, readdir, stat, unlink } from 'node:fs/promises'
import { writeFile } from './atomic-write.js'
import { assertPublicHttpsUrl } from './url-safety.js'
import path from 'node:path' import path from 'node:path'
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import { sanitizeSiteContent } from './helpers.js' import { sanitizeSiteContent } from './helpers.js'
@@ -23,7 +26,6 @@ import {
QR_CODES_FILE, QR_CODES_FILE,
REPLY_TEMPLATES_FILE, REPLY_TEMPLATES_FILE,
REPLY_HISTORY_FILE, REPLY_HISTORY_FILE,
PODCAST_CHECKLIST_FILE,
BACKUP_DIR, BACKUP_DIR,
UPLOADS_DIR, UPLOADS_DIR,
UPLOADS_META_FILE, UPLOADS_META_FILE,
@@ -31,9 +33,11 @@ import {
EPISODE_PLAYS_FILE, EPISODE_PLAYS_FILE,
ANALYTICS_EVENTS_FILE, ANALYTICS_EVENTS_FILE,
EMAIL_SETTINGS_FILE, EMAIL_SETTINGS_FILE,
CALENDAR_EVENTS_FILE,
AUDIT_LOG_FILE, AUDIT_LOG_FILE,
WEBHOOKS_FILE, WEBHOOKS_FILE,
STUDY_SESSIONS_FILE,
NEWSLETTER_ISSUES_FILE,
NUGGETS_TAGS_FILE,
EMPTY_HIT_STATS, EMPTY_HIT_STATS,
EMPTY_VISITOR_STATS, EMPTY_VISITOR_STATS,
DEFAULT_REPLY_TEMPLATES, DEFAULT_REPLY_TEMPLATES,
@@ -46,8 +50,6 @@ import {
MAX_STUDY_NOTES_PER_USER, MAX_STUDY_NOTES_PER_USER,
MAX_STUDY_NOTE_LENGTH, MAX_STUDY_NOTE_LENGTH,
MAX_STUDY_COMMENTS, MAX_STUDY_COMMENTS,
DEFAULT_PODCAST_CHECKLIST_TASKS,
buildDefaultPodcastChecklist,
} from './config.js' } from './config.js'
import { state } from './state.js' import { state } from './state.js'
@@ -165,6 +167,7 @@ export function loadHitStatsFromDisk() {
byPath: parsed?.byPath && typeof parsed.byPath === 'object' ? parsed.byPath : {}, byPath: parsed?.byPath && typeof parsed.byPath === 'object' ? parsed.byPath : {},
byPathReal: parsed?.byPathReal && typeof parsed.byPathReal === 'object' ? parsed.byPathReal : {}, byPathReal: parsed?.byPathReal && typeof parsed.byPathReal === 'object' ? parsed.byPathReal : {},
byPathBot: parsed?.byPathBot && typeof parsed.byPathBot === 'object' ? parsed.byPathBot : {}, byPathBot: parsed?.byPathBot && typeof parsed.byPathBot === 'object' ? parsed.byPathBot : {},
byPathUnknown: parsed?.byPathUnknown && typeof parsed.byPathUnknown === 'object' ? parsed.byPathUnknown : {},
byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {}, byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {},
byDayReal: parsed?.byDayReal && typeof parsed.byDayReal === 'object' ? parsed.byDayReal : {}, byDayReal: parsed?.byDayReal && typeof parsed.byDayReal === 'object' ? parsed.byDayReal : {},
byDayBot: parsed?.byDayBot && typeof parsed.byDayBot === 'object' ? parsed.byDayBot : {}, byDayBot: parsed?.byDayBot && typeof parsed.byDayBot === 'object' ? parsed.byDayBot : {},
@@ -419,36 +422,6 @@ export function loadEmailSettingsFromDisk() {
}) })
} }
// ── Calendar events ────────────────────────────────────────────────────────
export function queueCalendarEventsWrite() {
state.calendarEventsWritePromise = state.calendarEventsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
CALENDAR_EVENTS_FILE,
JSON.stringify({ events: state.calendarEvents, updatedAt: new Date().toISOString() }, null, 2),
'utf8',
)
})
.catch(err => {
console.error('[calendar-events] failed to write:', err)
})
}
export function loadCalendarEventsFromDisk() {
return readFile(CALENDAR_EVENTS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed?.events)) {
state.calendarEvents = parsed.events
}
})
.catch(() => {
// No file yet — start with empty array
})
}
// ── Audit log ───────────────────────────────────────────────────────────── // ── Audit log ─────────────────────────────────────────────────────────────
export function appendAuditEntry(action, details) { export function appendAuditEntry(action, details) {
@@ -496,6 +469,33 @@ export function loadWebhooksFromDisk() {
.catch(() => {}) .catch(() => {})
} }
// ── Study sessions ──────────────────────────────────────────────────────
export function queueStudySessionsWrite() {
state.studySessionsWritePromise = state.studySessionsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
const sessions = Array.from(state.studySessions.entries()).map(([token, session]) => ({ token, ...session }))
await writeFile(STUDY_SESSIONS_FILE, JSON.stringify({ sessions }, null, 2), 'utf8')
})
.catch(err => { console.error('[study-sessions] write failed:', err) })
}
export function loadStudySessionsFromDisk() {
return readFile(STUDY_SESSIONS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed?.sessions)) return
const now = Date.now()
for (const entry of parsed.sessions) {
if (!entry?.token || !entry?.userId || typeof entry.expiresAt !== 'number') continue
if (entry.expiresAt <= now) continue
state.studySessions.set(entry.token, { userId: entry.userId, expiresAt: entry.expiresAt })
}
})
.catch(() => {})
}
export async function fireWebhooks(eventType, payload) { export async function fireWebhooks(eventType, payload) {
const targets = state.webhooks.filter(w => const targets = state.webhooks.filter(w =>
Array.isArray(w.events) && (w.events.includes(eventType) || w.events.includes('*')) Array.isArray(w.events) && (w.events.includes(eventType) || w.events.includes('*'))
@@ -503,12 +503,14 @@ export async function fireWebhooks(eventType, payload) {
if (targets.length === 0) return if (targets.length === 0) return
const body = JSON.stringify({ event: eventType, data: payload, firedAt: new Date().toISOString() }) const body = JSON.stringify({ event: eventType, data: payload, firedAt: new Date().toISOString() })
for (const wh of targets) { for (const wh of targets) {
fetch(wh.url, { // Re-check at delivery time: DNS may have changed since the webhook was registered.
assertPublicHttpsUrl(wh.url).then(() => fetch(wh.url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'User-Agent': 'Siteforge-Webhook/1.0' }, headers: { 'Content-Type': 'application/json', 'User-Agent': 'Siteforge-Webhook/1.0' },
body, body,
redirect: 'manual',
signal: AbortSignal.timeout(8000), signal: AbortSignal.timeout(8000),
}) }))
.then(r => { .then(r => {
appendAuditEntry('webhook-fired', `${eventType} → ${wh.url} (${r.status})`) appendAuditEntry('webhook-fired', `${eventType} → ${wh.url} (${r.status})`)
}) })
@@ -518,32 +520,6 @@ export async function fireWebhooks(eventType, payload) {
} }
} }
// ── Podcast checklist ──────────────────────────────────────────────────────
export function queuePodcastChecklistWrite() {
const updatedAt = new Date().toISOString()
state.podcastChecklistWritePromise = state.podcastChecklistWritePromise.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
PODCAST_CHECKLIST_FILE,
JSON.stringify({ checklist: state.podcastChecklist, updatedAt }, null, 2),
'utf8',
)
})
return state.podcastChecklistWritePromise
}
export function loadPodcastChecklistFromDisk() {
return readFile(PODCAST_CHECKLIST_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
state.podcastChecklist = sanitizePodcastChecklist(parsed?.checklist)
})
.catch(() => {
state.podcastChecklist = buildDefaultPodcastChecklist()
})
}
// ── Study users ──────────────────────────────────────────────────────────── // ── Study users ────────────────────────────────────────────────────────────
export function queueStudyUsersWrite() { export function queueStudyUsersWrite() {
@@ -775,7 +751,11 @@ export async function loadEpisodeScriptsFromDisk() {
try { try {
const raw = await readFile(EPISODE_SCRIPTS_FILE, 'utf8') const raw = await readFile(EPISODE_SCRIPTS_FILE, 'utf8')
const parsed = JSON.parse(raw) const parsed = JSON.parse(raw)
state.episodeScripts = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {} const loaded = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}
const { scripts, changed } = migrateScriptKeys(loaded)
state.episodeScripts = scripts
// Persist the move to season-episode keys once, so it isn't redone on every start
if (changed) queueEpisodeScriptsWrite()
} catch { } catch {
state.episodeScripts = {} state.episodeScripts = {}
} }
@@ -959,23 +939,68 @@ export async function writeUploadsMetadata(metadata) {
} }
export async function listUploadedAssets() { export async function listUploadedAssets() {
await mkdir(UPLOADS_DIR, { recursive: true })
const files = await readdir(UPLOADS_DIR)
const imageFiles = files.filter(name => /\.(png|jpe?g|webp|gif|pdf|docx?)$/i.test(name)).sort()
const metadata = await readUploadsMetadata() const metadata = await readUploadsMetadata()
const withStats = await Promise.all(imageFiles.map(async filename => { // R2-backed assets (new uploads)
const info = await stat(path.join(UPLOADS_DIR, filename)) let r2Assets = []
return { try {
filename, const { isR2Configured, listR2Objects } = await import('./r2.js')
url: `/uploads/${filename}`, const { R2_PUBLIC_URL } = await import('./config.js')
sizeBytes: info.size, if (isR2Configured()) {
updatedAt: info.mtime.toISOString(), const objects = await listR2Objects('')
tags: Array.isArray(metadata[filename]) ? metadata[filename].filter(tag => typeof tag === 'string') : [], r2Assets = objects
.filter(obj => /\.(png|jpe?g|webp|gif|pdf|docx?)$/i.test(obj.Key))
.map(obj => {
const parts = obj.Key.split('/')
const filename = parts.pop()
const folder = parts.length > 0 ? parts[0] : ''
const entry = metadata[filename]
const tags = Array.isArray(entry)
? entry.filter(t => typeof t === 'string')
: Array.isArray(entry?.tags) ? entry.tags.filter(t => typeof t === 'string') : []
return {
filename,
key: obj.Key,
storage: 'r2',
url: `${R2_PUBLIC_URL}/${obj.Key}`,
folder,
sizeBytes: obj.Size ?? 0,
updatedAt: obj.LastModified ? new Date(obj.LastModified).toISOString() : null,
tags,
}
})
} }
})) } catch { /* R2 not available */ }
return withStats // Local files not already in R2 metadata
const r2Filenames = new Set(r2Assets.map(a => a.filename))
let localAssets = []
try {
await mkdir(UPLOADS_DIR, { recursive: true })
const files = await readdir(UPLOADS_DIR)
const localFiles = files
.filter(name => /\.(png|jpe?g|webp|gif|pdf|docx?)$/i.test(name) && !r2Filenames.has(name))
.sort()
localAssets = await Promise.all(localFiles.map(async filename => {
const info = await stat(path.join(UPLOADS_DIR, filename))
const entry = metadata[filename]
const tags = Array.isArray(entry)
? entry.filter(t => typeof t === 'string')
: Array.isArray(entry?.tags) ? entry.tags.filter(t => typeof t === 'string') : []
return {
filename,
key: filename,
storage: 'local',
url: `/uploads/${filename}`,
folder: 'local',
sizeBytes: info.size,
updatedAt: info.mtime.toISOString(),
tags,
}
}))
} catch { /* no local uploads dir */ }
return [...r2Assets, ...localAssets].sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
} }
// ── Backups ──────────────────────────────────────────────────────────────── // ── Backups ────────────────────────────────────────────────────────────────
@@ -991,10 +1016,11 @@ export async function createBackupSnapshot(reason = 'scheduled') {
reason, reason,
adminContent: null, adminContent: null,
draftContent: null, draftContent: null,
podcastChecklist: state.podcastChecklist,
publishState: state.publishState, publishState: state.publishState,
hitStats: state.hitStats, hitStats: state.hitStats,
visitorStats: state.visitorStats, visitorStats: state.visitorStats,
analyticsEvents: state.analyticsEvents,
episodePlays: state.episodePlays,
contactSubmissions: state.contactSubmissions, contactSubmissions: state.contactSubmissions,
studyCommunityPosts: state.studyCommunityPosts, studyCommunityPosts: state.studyCommunityPosts,
replyTemplates: state.replyTemplates, replyTemplates: state.replyTemplates,
@@ -1110,25 +1136,28 @@ export async function restoreFromBackup(filename) {
state.hitStats = sanitizeLoadedHitStats(parsed?.hitStats) state.hitStats = sanitizeLoadedHitStats(parsed?.hitStats)
state.visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats) state.visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats)
state.analyticsEvents = sanitizeLoadedAnalyticsEvents(parsed?.analyticsEvents)
state.episodePlays = sanitizeLoadedEpisodePlays(parsed?.episodePlays)
state.contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions) state.contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions)
state.replyTemplates = sanitizeReplyTemplates(parsed?.replyTemplates) state.replyTemplates = sanitizeReplyTemplates(parsed?.replyTemplates)
state.replyHistory = sanitizeReplyHistory(parsed?.replyHistory) state.replyHistory = sanitizeReplyHistory(parsed?.replyHistory)
state.podcastChecklist = sanitizePodcastChecklist(parsed?.podcastChecklist)
queueHitStatsWrite() queueHitStatsWrite()
queueVisitorStatsWrite() queueVisitorStatsWrite()
queueAnalyticsEventsWrite()
queueEpisodePlaysWrite()
queueContactSubmissionsWrite() queueContactSubmissionsWrite()
queueReplyTemplatesWrite() queueReplyTemplatesWrite()
queueReplyHistoryWrite() queueReplyHistoryWrite()
queuePodcastChecklistWrite()
await Promise.all([ await Promise.all([
state.hitStatsWritePromise, state.hitStatsWritePromise,
state.visitorStatsWritePromise, state.visitorStatsWritePromise,
state.analyticsEventsWritePromise,
state.episodePlaysWritePromise,
state.contactSubmissionsWritePromise, state.contactSubmissionsWritePromise,
state.replyTemplatesWritePromise, state.replyTemplatesWritePromise,
state.replyHistoryWritePromise, state.replyHistoryWritePromise,
state.podcastChecklistWritePromise,
]) ])
await refreshContentCaches() await refreshContentCaches()
await createBackupSnapshot('post-restore') await createBackupSnapshot('post-restore')
@@ -1138,7 +1167,7 @@ export async function restoreFromBackup(filename) {
// (These are defined here rather than study-helpers to avoid circular imports) // (These are defined here rather than study-helpers to avoid circular imports)
export function normalizeMessageType(value) { export function normalizeMessageType(value) {
if (value === 'question' || value === 'testimony' || value === 'topic') return value if (value === 'question' || value === 'testimony' || value === 'topic' || value === 'gospel' || value === 'community') return value
return 'general' return 'general'
} }
@@ -1222,6 +1251,7 @@ function sanitizeLoadedHitStats(value) {
byPath: value?.byPath && typeof value.byPath === 'object' ? value.byPath : {}, byPath: value?.byPath && typeof value.byPath === 'object' ? value.byPath : {},
byPathReal: value?.byPathReal && typeof value.byPathReal === 'object' ? value.byPathReal : {}, byPathReal: value?.byPathReal && typeof value.byPathReal === 'object' ? value.byPathReal : {},
byPathBot: value?.byPathBot && typeof value.byPathBot === 'object' ? value.byPathBot : {}, byPathBot: value?.byPathBot && typeof value.byPathBot === 'object' ? value.byPathBot : {},
byPathUnknown: value?.byPathUnknown && typeof value.byPathUnknown === 'object' ? value.byPathUnknown : {},
byDay: value?.byDay && typeof value.byDay === 'object' ? value.byDay : {}, byDay: value?.byDay && typeof value.byDay === 'object' ? value.byDay : {},
byDayReal: value?.byDayReal && typeof value.byDayReal === 'object' ? value.byDayReal : {}, byDayReal: value?.byDayReal && typeof value.byDayReal === 'object' ? value.byDayReal : {},
byDayBot: value?.byDayBot && typeof value.byDayBot === 'object' ? value.byDayBot : {}, byDayBot: value?.byDayBot && typeof value.byDayBot === 'object' ? value.byDayBot : {},
@@ -1229,6 +1259,23 @@ function sanitizeLoadedHitStats(value) {
} }
} }
function sanitizeLoadedAnalyticsEvents(value) {
return {
scrollDepth: value?.scrollDepth && typeof value.scrollDepth === 'object' ? value.scrollDepth : {},
timeOnPage: value?.timeOnPage && typeof value.timeOnPage === 'object' ? value.timeOnPage : {},
outboundClicks: value?.outboundClicks && typeof value.outboundClicks === 'object' ? value.outboundClicks : {},
linkClicks: value?.linkClicks && typeof value.linkClicks === 'object' ? value.linkClicks : {},
utmSources: value?.utmSources && typeof value.utmSources === 'object' ? value.utmSources : {},
searchQueries: value?.searchQueries && typeof value.searchQueries === 'object' ? value.searchQueries : {},
notFound: value?.notFound && typeof value.notFound === 'object' ? value.notFound : {},
audioEvents: value?.audioEvents && typeof value.audioEvents === 'object' ? value.audioEvents : {},
}
}
function sanitizeLoadedEpisodePlays(value) {
return (value && typeof value === 'object' && !Array.isArray(value)) ? value : {}
}
function sanitizeLoadedVisitorStats(value) { function sanitizeLoadedVisitorStats(value) {
const loadedVisitors = value?.visitors && typeof value.visitors === 'object' ? value.visitors : {} const loadedVisitors = value?.visitors && typeof value.visitors === 'object' ? value.visitors : {}
@@ -1285,75 +1332,6 @@ export function sanitizeReplyHistory(value) {
})) }))
} }
function sanitizeChecklistTask(task) {
const label = typeof task?.label === 'string' ? task.label.trim().slice(0, 120) : ''
if (!label) return null
const phase = task?.phase === 'post' ? 'post' : 'pre'
const id = typeof task?.id === 'string' && task.id.trim() ? task.id.trim() : randomUUID()
return { id, label, phase }
}
export function sanitizePodcastChecklist(value) {
const fallback = buildDefaultPodcastChecklist()
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
const taskInput = Array.isArray(source.tasks) ? source.tasks : fallback.tasks
const seenTaskIds = new Set()
const tasks = []
for (const item of taskInput) {
const safeTask = sanitizeChecklistTask(item)
if (!safeTask) continue
if (seenTaskIds.has(safeTask.id)) continue
seenTaskIds.add(safeTask.id)
tasks.push(safeTask)
}
if (tasks.length === 0) {
for (const task of fallback.tasks) {
tasks.push({ ...task })
seenTaskIds.add(task.id)
}
}
const taskIds = tasks.map(task => task.id)
const episodesInput = Array.isArray(source.episodes) ? source.episodes : fallback.episodes
const episodes = []
for (const item of episodesInput) {
if (!item || typeof item !== 'object' || Array.isArray(item)) continue
const id = typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID()
const series = typeof item.series === 'string' ? item.series.trim().slice(0, 80) : ''
const title = typeof item.title === 'string' ? item.title.trim().slice(0, 180) : ''
const rawEpisodeNumber = Number(item.episodeNumber)
const episodeNumber = Number.isFinite(rawEpisodeNumber) && rawEpisodeNumber >= 0
? Math.round(rawEpisodeNumber)
: null
const datePublished = typeof item.datePublished === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(item.datePublished.trim())
? item.datePublished.trim()
: ''
const sourceTasks = item.tasks && typeof item.tasks === 'object' && !Array.isArray(item.tasks)
? item.tasks
: {}
const taskState = {}
for (const taskId of taskIds) {
taskState[taskId] = sourceTasks[taskId] === true
}
const reminderDays = Number.isFinite(Number(item.reminderDays)) && Number(item.reminderDays) >= 0 ? Number(item.reminderDays) : 0
const reminderSentAt = typeof item.reminderSentAt === 'string' && item.reminderSentAt ? item.reminderSentAt : undefined
const startTime = typeof item.startTime === 'string' && /^\d{2}:\d{2}$/.test(item.startTime) ? item.startTime : undefined
const PROD_STATUSES = ['idea', 'recorded', 'edited', 'scheduled', 'published']
const productionStatus = PROD_STATUSES.includes(item.productionStatus) ? item.productionStatus : undefined
episodes.push({ id, series, episodeNumber, title, datePublished, expanded: item.expanded === true, tasks: taskState, reminderDays, reminderSentAt, ...(startTime ? { startTime } : {}), ...(productionStatus ? { productionStatus } : {}) })
}
if (episodes.length === 0) {
return fallback
}
return { tasks, episodes }
}
function normalizeStudyUsername(value) { function normalizeStudyUsername(value) {
if (typeof value !== 'string') return '' if (typeof value !== 'string') return ''
return value.trim().toLowerCase() return value.trim().toLowerCase()
@@ -1381,6 +1359,9 @@ export function sanitizeStudyUsers(value) {
const enrolledStudySlugs = Array.isArray(item?.enrolledStudySlugs) const enrolledStudySlugs = Array.isArray(item?.enrolledStudySlugs)
? Array.from(new Set(item.enrolledStudySlugs.map(normalizeStudySlug).filter(Boolean))).slice(0, MAX_STUDY_ENROLLMENTS_PER_USER) ? Array.from(new Set(item.enrolledStudySlugs.map(normalizeStudySlug).filter(Boolean))).slice(0, MAX_STUDY_ENROLLMENTS_PER_USER)
: [] : []
const completedStudySlugs = Array.isArray(item?.completedStudySlugs)
? Array.from(new Set(item.completedStudySlugs.map(normalizeStudySlug).filter(Boolean))).slice(0, MAX_STUDY_ENROLLMENTS_PER_USER * 4)
: []
const displayName = typeof item?.displayName === 'string' ? item.displayName.trim().slice(0, 80) : '' const displayName = typeof item?.displayName === 'string' ? item.displayName.trim().slice(0, 80) : ''
const subscribeNewsletter = item?.subscribeNewsletter !== false const subscribeNewsletter = item?.subscribeNewsletter !== false
const studyRemindersEnabled = item?.studyRemindersEnabled === true const studyRemindersEnabled = item?.studyRemindersEnabled === true
@@ -1394,6 +1375,18 @@ export function sanitizeStudyUsers(value) {
requestedAt: typeof item.pendingEmailChange.requestedAt === 'string' ? item.pendingEmailChange.requestedAt : null, requestedAt: typeof item.pendingEmailChange.requestedAt === 'string' ? item.pendingEmailChange.requestedAt : null,
} }
: null : null
const pendingEmailVerification = item?.pendingEmailVerification && typeof item.pendingEmailVerification === 'object' && !Array.isArray(item.pendingEmailVerification)
? {
tokenHash: typeof item.pendingEmailVerification.tokenHash === 'string' ? item.pendingEmailVerification.tokenHash.trim() : '',
expiresAt: typeof item.pendingEmailVerification.expiresAt === 'number' ? item.pendingEmailVerification.expiresAt : 0,
}
: null
const pendingPasswordReset = item?.pendingPasswordReset && typeof item.pendingPasswordReset === 'object' && !Array.isArray(item.pendingPasswordReset)
? {
tokenHash: typeof item.pendingPasswordReset.tokenHash === 'string' ? item.pendingPasswordReset.tokenHash.trim() : '',
expiresAt: typeof item.pendingPasswordReset.expiresAt === 'number' ? item.pendingPasswordReset.expiresAt : 0,
}
: 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(),
@@ -1403,18 +1396,32 @@ export function sanitizeStudyUsers(value) {
subscribeNewsletter, subscribeNewsletter,
studyRemindersEnabled, studyRemindersEnabled,
enrolledStudySlugs, enrolledStudySlugs,
completedStudySlugs,
// Only the admin panel can set this (see PATCH /api/admin/study-users/:id)
adminShortcut: item?.adminShortcut === true,
currentStreak: Number.isFinite(item?.currentStreak) && item.currentStreak > 0 ? Math.floor(item.currentStreak) : 0,
longestStreak: Number.isFinite(item?.longestStreak) && item.longestStreak > 0 ? Math.floor(item.longestStreak) : 0,
lastStudiedDate: typeof item?.lastStudiedDate === 'string' ? item.lastStudiedDate : null,
firstCompletionAt: typeof item?.firstCompletionAt === 'string' ? item.firstCompletionAt : null,
avatarUrl: typeof item?.avatarUrl === 'string' ? item.avatarUrl.trim() : '', avatarUrl: typeof item?.avatarUrl === 'string' ? item.avatarUrl.trim() : '',
createdAt: typeof item?.createdAt === 'string' ? item.createdAt : null, createdAt: typeof item?.createdAt === 'string' ? item.createdAt : null,
updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : null, updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : null,
lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null, lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null,
reengagementSentAt: typeof item?.reengagementSentAt === 'object' && item.reengagementSentAt !== null && !Array.isArray(item.reengagementSentAt) ? item.reengagementSentAt : {}, reengagementSentAt: typeof item?.reengagementSentAt === 'object' && item.reengagementSentAt !== null && !Array.isArray(item.reengagementSentAt) ? item.reengagementSentAt : {},
pendingEmailChange, pendingEmailChange,
emailVerified: item?.emailVerified === true,
pendingEmailVerification,
pendingPasswordReset,
twoFaMethod: item?.twoFaMethod === 'app' || item?.twoFaMethod === 'email' ? item.twoFaMethod : null, twoFaMethod: item?.twoFaMethod === 'app' || item?.twoFaMethod === 'email' ? item.twoFaMethod : null,
totpSecret: typeof item?.totpSecret === 'string' && item.totpSecret ? item.totpSecret : null, totpSecret: typeof item?.totpSecret === 'string' && item.totpSecret ? item.totpSecret : null,
totpVerified: item?.totpVerified === true, totpVerified: item?.totpVerified === true,
totpEnabledAt: typeof item?.totpEnabledAt === 'string' ? item.totpEnabledAt : null, totpEnabledAt: typeof item?.totpEnabledAt === 'string' ? item.totpEnabledAt : null,
totpRecoveryCodes: Array.isArray(item?.totpRecoveryCodes) ? item.totpRecoveryCodes.filter(h => typeof h === 'string') : [], totpRecoveryCodes: Array.isArray(item?.totpRecoveryCodes) ? item.totpRecoveryCodes.filter(h => typeof h === 'string') : [],
totpSecretPending: typeof item?.totpSecretPending === 'string' ? item.totpSecretPending : undefined, totpSecretPending: typeof item?.totpSecretPending === 'string' ? item.totpSecretPending : undefined,
remindersPausedUntil: typeof item?.remindersPausedUntil === 'string' ? item.remindersPausedUntil : null,
reminderFrequency: item?.reminderFrequency === 'weekly' ? 'weekly' : 'immediate',
weeklyDigestSentAt: typeof item?.weeklyDigestSentAt === 'string' ? item.weeklyDigestSentAt : null,
remindersDefaultedAt: typeof item?.remindersDefaultedAt === 'string' ? item.remindersDefaultedAt : null,
}) })
} }
return out.slice(0, MAX_STUDY_USERS) return out.slice(0, MAX_STUDY_USERS)
@@ -1459,8 +1466,12 @@ export function sanitizeStudyProgress(value) {
]) ])
) )
: {} : {}
const bookmarkedSectionIds = Array.isArray(studyData?.bookmarkedSectionIds)
? Array.from(new Set(studyData.bookmarkedSectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim())))
: []
progress.byStudy[studySlug.trim().toLowerCase()] = { progress.byStudy[studySlug.trim().toLowerCase()] = {
completedSectionIds: Array.from(new Set(completedSectionIds)), completedSectionIds: Array.from(new Set(completedSectionIds)),
bookmarkedSectionIds,
quizAnswers, quizAnswers,
} }
} }
@@ -1516,6 +1527,7 @@ export function sanitizeStudyCommunityPosts(value) {
? item.replies.map(sanitizeStudyCommunityReply).filter(Boolean).slice(0, 50) ? item.replies.map(sanitizeStudyCommunityReply).filter(Boolean).slice(0, 50)
: [] : []
if (!studySlug || !message) return null if (!studySlug || !message) return null
const likedBy = Array.isArray(item.likedBy) ? item.likedBy.filter(id => typeof id === 'string' && id.trim()).slice(0, 1000) : []
return { return {
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
studySlug, studySlug,
@@ -1525,6 +1537,7 @@ export function sanitizeStudyCommunityPosts(value) {
message, message,
createdAt: typeof item.createdAt === 'string' ? item.createdAt : new Date().toISOString(), createdAt: typeof item.createdAt === 'string' ? item.createdAt : new Date().toISOString(),
replies, replies,
likedBy,
} }
}) })
.filter(Boolean) .filter(Boolean)
@@ -1555,3 +1568,51 @@ export function loadQrCodesFromDisk() {
state.qrScans = [] state.qrScans = []
}) })
} }
// ── Newsletter Issues ────────────────────────────────────────────────────────
export function queueNewsletterIssuesWrite() {
state.newsletterIssuesWritePromise = state.newsletterIssuesWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(NEWSLETTER_ISSUES_FILE, JSON.stringify({ issues: state.newsletterIssues }, null, 2), 'utf8')
})
.catch(err => {
console.error('[newsletter-issues] failed to write:', err)
})
}
export function loadNewsletterIssuesFromDisk() {
return readFile(NEWSLETTER_ISSUES_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
state.newsletterIssues = Array.isArray(parsed?.issues) ? parsed.issues : []
})
.catch(() => {
state.newsletterIssues = []
})
}
// ── Nuggets Episode Tags ─────────────────────────────────────────────────────
export function queueNuggetsTagsWrite() {
state.nuggetsEpisodeTagsWritePromise = state.nuggetsEpisodeTagsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(NUGGETS_TAGS_FILE, JSON.stringify({ tags: state.nuggetsEpisodeTags }, null, 2), 'utf8')
})
.catch(err => {
console.error('[nuggets-tags] failed to write:', err)
})
}
export function loadNuggetsTagsFromDisk() {
return readFile(NUGGETS_TAGS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
state.nuggetsEpisodeTags = parsed?.tags && typeof parsed.tags === 'object' ? parsed.tags : {}
})
.catch(() => {
state.nuggetsEpisodeTags = {}
})
}
+333 -8
View File
@@ -2,6 +2,8 @@ import { Resend } from 'resend'
import { escapeHtml, buildAbsoluteUrl, splitName } from './helpers.js' import { escapeHtml, buildAbsoluteUrl, splitName } from './helpers.js'
import { import {
DEFAULT_RESEND_FROM, DEFAULT_RESEND_FROM,
DEFAULT_RESEND_TO,
DEFAULT_RESEND_REPLY_TO,
DEFAULT_SEO, DEFAULT_SEO,
} from './config.js' } from './config.js'
import { state } from './state.js' import { state } from './state.js'
@@ -13,11 +15,11 @@ export function getResendFromAddress() {
} }
export function getResendReplyToAddress() { export function getResendReplyToAddress() {
return process.env.RESEND_REPLY_TO ?? 'hello@versebyversewithnate.us' return process.env.RESEND_REPLY_TO ?? DEFAULT_RESEND_REPLY_TO
} }
export function getResendInboxAddress() { export function getResendInboxAddress() {
return process.env.RESEND_TO ?? 'hello@versebyversewithnate.us' return process.env.RESEND_TO ?? DEFAULT_RESEND_TO
} }
export function getCanonicalBaseUrl() { export function getCanonicalBaseUrl() {
@@ -82,22 +84,40 @@ export async function sendResendEmailWithRetry({ resend, payload, context, maxAt
throw lastError ?? new Error(`[${context}] email send failed`) throw lastError ?? new Error(`[${context}] email send failed`)
} }
// Retry a Resend SDK call when the account's rate limit (429) is hit. The SDK returns { error } rather than throwing.
export async function resendRetry(call, maxAttempts = 5) {
let result
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
result = await call()
const err = result?.error
const limited = err && (err.statusCode === 429 || err.name === 'rate_limit_exceeded')
if (!limited) return result
await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** attempt))
}
return result
}
export async function syncContactToResend(name, email) { export async function syncContactToResend(name, email) {
if (!process.env.RESEND_API_KEY) return if (!process.env.RESEND_API_KEY) return
const { firstName, lastName } = splitName(name) const { firstName, lastName } = splitName(name)
const contactResend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY) const contactResend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
try { try {
const { error: contactError } = await contactResend.contacts.create({ // Never touch `unsubscribed` for a contact that already exists — otherwise a form submission
// would silently resubscribe someone who opted out in Resend.
const { data: existing } = await resendRetry(() => contactResend.contacts.get({ email }))
if (existing) {
const { error: updateError } = await resendRetry(() => contactResend.contacts.update({ email, firstName, lastName }))
if (updateError) console.error('[resend] contact sync error:', updateError)
return
}
const { error: createError } = await resendRetry(() => contactResend.contacts.create({
email, email,
firstName, firstName,
lastName, lastName,
unsubscribed: false, unsubscribed: false,
...(process.env.RESEND_SEGMENT_ID ? { segments: [{ id: process.env.RESEND_SEGMENT_ID }] } : {}), ...(process.env.RESEND_SEGMENT_ID ? { segments: [{ id: process.env.RESEND_SEGMENT_ID }] } : {}),
}) }))
if (contactError) { if (createError) console.error('[resend] contact sync error:', createError)
const { error: updateError } = await contactResend.contacts.update({ email, firstName, lastName, unsubscribed: false })
if (updateError) console.error('[resend] contact sync error:', updateError)
}
} catch (err) { } catch (err) {
console.error('[resend] contact sync exception:', err) console.error('[resend] contact sync exception:', err)
} }
@@ -483,6 +503,106 @@ export async function sendStudyWelcomeEmail(email, displayName) {
} }
} }
export async function sendStudyVerifyEmail(email, displayName, verifyUrl) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const cfg = state.cachedSiteContent ?? {}
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
const subject = cfg.emailVerifySubject?.trim() || 'Verify your email address'
const bodyText = cfg.emailVerifyBody?.trim() || 'Please confirm this is your email address so we can keep your study account secure and deliver reminders you opt into.'
const ctaLabel = cfg.emailVerifyCtaLabel?.trim() || 'Verify Email Address'
const bodyHtml = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>`
)
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
to: [email],
subject,
text: `Hi ${namePart},\n\n${bodyText}\n\n${verifyUrl}\n\nVerse by Verse with Nate`,
html: buildBrandedEmailHtml({
title: 'Verify Your Email',
eyebrow: 'Study Account',
bodyHtml,
ctaLabel,
ctaUrl: verifyUrl,
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">If you didn't create this account, you can ignore this message.</p>`,
}),
})
if (error) console.error('[study-verify-email] send error:', error)
} catch (err) {
console.error('[study-verify-email] send exception:', err)
}
}
export async function sendStudyPasswordResetEmail(email, displayName, resetUrl) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const cfg = state.cachedSiteContent ?? {}
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
const subject = cfg.passwordResetSubject?.trim() || 'Reset your password'
const bodyText = cfg.passwordResetBody?.trim() || 'We received a request to reset the password on your study account. Click below to choose a new password. This link expires in 1 hour.'
const ctaLabel = cfg.passwordResetCtaLabel?.trim() || 'Reset Password'
const bodyHtml = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>`
)
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
to: [email],
subject,
text: `Hi ${namePart},\n\n${bodyText}\n\n${resetUrl}\n\nIf you didn't request this, you can safely ignore this email — your password won't change.\n\nVerse by Verse with Nate`,
html: buildBrandedEmailHtml({
title: 'Reset Your Password',
eyebrow: 'Account Security',
bodyHtml,
ctaLabel,
ctaUrl: resetUrl,
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">If you didn't request this, you can safely ignore this email — your password won't change.</p>`,
}),
})
if (error) console.error('[study-password-reset] send error:', error)
} catch (err) {
console.error('[study-password-reset] send exception:', err)
}
}
export async function sendStudyPasswordChangedEmail(email, displayName) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const cfg = state.cachedSiteContent ?? {}
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
const baseUrl = getCanonicalBaseUrl()
const accountUrl = buildAbsoluteUrl(baseUrl, '/study/account')
const subject = cfg.passwordChangedSubject?.trim() || 'Your password was changed'
const bodyText = cfg.passwordChangedBody?.trim() || 'This confirms the password on your study account was just changed. All other active sessions have been signed out. If this wasn’t you, please contact us right away.'
const bodyHtml = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>`
)
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
to: [email],
subject,
text: `Hi ${namePart},\n\n${bodyText}\n\nManage your account: ${accountUrl}\n\nVerse by Verse with Nate`,
html: buildBrandedEmailHtml({
title: 'Password Changed',
eyebrow: 'Account Security',
bodyHtml,
ctaLabel: 'Manage Account',
ctaUrl: accountUrl,
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">Verse by Verse with Nate</p>`,
}),
})
if (error) console.error('[study-password-changed] send error:', error)
} catch (err) {
console.error('[study-password-changed] send exception:', err)
}
}
export async function sendEmailOtp(email, code) { export async function sendEmailOtp(email, code) {
if (!process.env.RESEND_API_KEY) return if (!process.env.RESEND_API_KEY) return
try { try {
@@ -635,3 +755,208 @@ export async function sendStudyReengagementEmail(email, displayName, studyTitle,
console.error('[study-reengagement] send exception:', err) console.error('[study-reengagement] send exception:', err)
} }
} }
export async function sendStudyCertificateEmail(email, displayName, studyTitle, certUrl) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const cfg = state.cachedSiteContent ?? {}
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
const subject = cfg.studyCertificateEmailSubject?.trim() || 'Your certificate is ready'
const bodyText = cfg.studyCertificateEmailBody?.trim() || 'Congratulations — you have completed every lesson in this study. Your certificate is ready to view and share.'
const ctaLabel = cfg.studyCertificateEmailCtaLabel?.trim() || 'View Certificate'
const signoff = cfg.studyCertificateEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate'
const bodyHtml = (
`<p style="margin:0 0 16px;">Congratulations, <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>!</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>` +
`<p style="margin:0 0 16px;color:#c9a84c;font-weight:700;">${escapeHtml(studyTitle)}</p>`
)
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">${escapeHtml(signoff).replace(/\n/g, '<br/>')}</p>`
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
to: [email],
subject,
text: `Congratulations, ${namePart}!\n\n${bodyText}\n\n${studyTitle}\n\nView your certificate: ${certUrl}\n\n${signoff}`,
html: buildBrandedEmailHtml({
title: 'Study Complete',
eyebrow: 'Certificate of Completion',
bodyHtml,
ctaLabel,
ctaUrl: certUrl,
footerHtml,
}),
})
if (error) console.error('[study-certificate] email send error:', error)
} catch (err) {
console.error('[study-certificate] email exception:', err)
}
}
export async function sendStudyWeeklyDigestEmail(email, displayName, lessons, unsubUrl) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const cfg = state.cachedSiteContent ?? {}
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
const subject = cfg.studyWeeklyDigestEmailSubject?.trim() || 'Your weekly study digest'
const introText = cfg.studyWeeklyDigestEmailBody?.trim() || 'Here are the lessons released this week in your study track. Open any to continue where you left off.'
const signoff = cfg.studyWeeklyDigestEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate'
const lessonListHtml = lessons.map(l =>
`<p style="margin:0 0 12px;"><a href="${escapeHtml(l.sectionUrl)}" style="color:#c9a84c;text-decoration:none;font-weight:700;">${escapeHtml(l.sectionTitle)}</a>` +
(l.sectionReference ? ` <span style="color:#7a7060;">(${escapeHtml(l.sectionReference)})</span>` : '') +
(l.studyTitle ? ` <span style="color:#7a7060;font-size:0.9em;">— ${escapeHtml(l.studyTitle)}</span>` : '') +
`</p>`
).join('')
const lessonListText = lessons.map(l => `• ${l.sectionTitle}${l.sectionReference ? ` (${l.sectionReference})` : ''}: ${l.sectionUrl}`).join('\n')
const bodyHtml = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 20px;">${escapeHtml(introText)}</p>` +
`<div style="border-left:2px solid #2a2518;padding-left:16px;">${lessonListHtml}</div>`
)
const unsubLine = unsubUrl ? `<p style="margin:16px 0 0;font-size:11px;color:#7a7060;">Not interested? <a href="${escapeHtml(unsubUrl)}" style="color:#7a7060;">Unsubscribe from these reminders.</a></p>` : ''
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">${escapeHtml(signoff).replace(/\n/g, '<br/>')}</p>${unsubLine}`
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
to: [email],
subject,
text: `Hi ${namePart},\n\n${introText}\n\n${lessonListText}\n\n${signoff}${unsubUrl ? `\n\nUnsubscribe: ${unsubUrl}` : ''}`,
html: buildBrandedEmailHtml({
title: 'Weekly Study Digest',
eyebrow: 'Study Reminder',
bodyHtml,
footerHtml,
}),
})
if (error) console.error('[study-digest] send error:', error)
} catch (err) {
console.error('[study-digest] send exception:', err)
}
}
// ── Nuggets upload notifications ───────────────────────────────────────────
export async function sendNuggetsUploadNotification({ to, source, hostName, episodeName, passage, description, filename, infoFile }) {
if (!process.env.RESEND_API_KEY) return
const resend = new Resend(process.env.RESEND_API_KEY)
const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
const sourceLabel = source === 'portal' ? 'Upload Portal' : 'Direct to Seafile'
const subject = `New Nuggets Episode Upload: ${episodeName || filename}`
const bodyHtml = `
<p style="margin:0 0 16px;font-family:Georgia,serif;font-size:15px;color:#c8c0ac;">A new episode file has arrived in Seafile.</p>
<table style="width:100%;border-collapse:collapse;font-family:Georgia,serif;font-size:14px;">
<tr><td style="padding:6px 0;color:#7a7060;width:120px;">Source</td><td style="padding:6px 0;font-weight:600;color:#f0ead8;">${escapeHtml(sourceLabel)}</td></tr>
${hostName ? `<tr><td style="padding:6px 0;color:#7a7060;">Host</td><td style="padding:6px 0;color:#c8c0ac;">${escapeHtml(hostName)}</td></tr>` : ''}
${episodeName ? `<tr><td style="padding:6px 0;color:#7a7060;">Episode</td><td style="padding:6px 0;font-weight:600;color:#f0ead8;">${escapeHtml(episodeName)}</td></tr>` : ''}
${passage ? `<tr><td style="padding:6px 0;color:#7a7060;">Passage</td><td style="padding:6px 0;color:#c8c0ac;">${escapeHtml(passage)}</td></tr>` : ''}
<tr><td style="padding:6px 0;color:#7a7060;">Audio File</td><td style="padding:6px 0;font-family:monospace;font-size:13px;color:#c9a84c;">${escapeHtml(filename)}</td></tr>
${infoFile ? `<tr><td style="padding:6px 0;color:#7a7060;">Info File</td><td style="padding:6px 0;font-family:monospace;font-size:13px;color:#7a7060;">${escapeHtml(infoFile)}</td></tr>` : ''}
</table>
${description ? `<p style="margin:16px 0 0;font-family:Georgia,serif;font-size:14px;color:#c8c0ac;"><strong style="color:#f0ead8;">Description:</strong><br/>${escapeHtml(description).replace(/\n/g, '<br/>')}</p>` : ''}
`
const textBody = [
`New Nuggets Episode Upload (${sourceLabel})`,
'',
hostName ? `Host: ${hostName}` : '',
episodeName ? `Episode: ${episodeName}` : '',
passage ? `Passage: ${passage}` : '',
`Audio File: ${filename}`,
infoFile ? `Info File: ${infoFile}` : '',
description ? `\nDescription:\n${description}` : '',
].filter(Boolean).join('\n')
try {
const { error } = await resend.emails.send({
from,
to: [to],
subject,
text: textBody,
html: buildBrandedEmailHtml({
title: 'New Episode Upload',
eyebrow: 'Nuggets',
bodyHtml,
footerHtml: '<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;color:#7a7060;">This notification was sent because a file was uploaded to the Nuggets Seafile library.</p>',
}),
})
if (error) console.error('[nuggets-notify] send error:', error)
} catch (err) {
console.error('[nuggets-notify] send exception:', err)
}
}
export async function sendNuggetsHostWelcomeEmail({ to, name, username, password, portalUrl }) {
if (!process.env.RESEND_API_KEY || !to) return
const resend = new Resend(process.env.RESEND_API_KEY)
const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
const bodyHtml = `
<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:15px;color:#c8c0ac;">Hi <strong style="color:#f0ead8;">${escapeHtml(name)}</strong>,</p>
<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:15px;color:#c8c0ac;">
You've been set up as a host on the <strong style="color:#f0ead8;">Nuggets</strong> episode upload portal.
Use the details below to sign in and submit your episodes.
</p>
<table style="width:100%;border-collapse:collapse;font-family:Georgia,serif;font-size:14px;margin-bottom:20px;">
<tr><td style="padding:8px 12px;background:#1a1a15;border:1px solid #2a2518;color:#7a7060;width:110px;">Portal URL</td><td style="padding:8px 12px;background:#1a1a15;border:1px solid #2a2518;"><a href="${escapeHtml(portalUrl)}" style="color:#c9a84c;">${escapeHtml(portalUrl)}</a></td></tr>
<tr><td style="padding:8px 12px;background:#1a1a15;border:1px solid #2a2518;color:#7a7060;">Email</td><td style="padding:8px 12px;background:#1a1a15;border:1px solid #2a2518;font-family:monospace;font-weight:700;color:#f0ead8;">${escapeHtml(username)}</td></tr>
<tr><td style="padding:8px 12px;background:#1a1a15;border:1px solid #2a2518;color:#7a7060;">Password</td><td style="padding:8px 12px;background:#1a1a15;border:1px solid #2a2518;font-family:monospace;font-weight:700;color:#f0ead8;">${escapeHtml(password)}</td></tr>
</table>
<p style="margin:0 0 10px;font-family:Georgia,serif;font-size:14px;color:#c8c0ac;">When you're ready to submit an episode, fill in the episode name, the Bible passage, a short description, and attach your audio file. Everything uploads in one step.</p>
<p style="margin:0;font-family:Georgia,serif;font-size:14px;color:#7a7060;">If you have any trouble signing in, reply to this email and we'll get you sorted.</p>
`
try {
const { error } = await resend.emails.send({
from,
to: [to],
subject: 'Your Nuggets Host Upload Portal Access',
text: `Hi ${name},\n\nYou've been set up as a host on the Nuggets episode upload portal.\n\nPortal URL: ${portalUrl}\nUsername: ${username}\nPassword: ${password}\n\nWhen submitting an episode, fill in the episode name, Bible passage, a short description, and attach your audio file.\n\nIf you have any trouble, reply to this email.`,
html: buildBrandedEmailHtml({
title: 'Upload Portal Access',
eyebrow: 'Nuggets',
bodyHtml,
ctaLabel: 'Open Upload Portal',
ctaUrl: portalUrl,
footerHtml: '<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;color:#7a7060;">You received this because an admin created an upload account for you on the Nuggets podcast portal.</p>',
}),
})
if (error) console.error('[nuggets-welcome] send error:', error)
} catch (err) {
console.error('[nuggets-welcome] send exception:', err)
}
}
export async function sendNuggetsHostOtpEmail({ to, name, code }) {
if (!process.env.RESEND_API_KEY || !to) return
const resend = new Resend(process.env.RESEND_API_KEY)
const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
const bodyHtml = `
<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:15px;color:#c8c0ac;">Hi <strong style="color:#f0ead8;">${escapeHtml(name)}</strong>,</p>
<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:15px;color:#c8c0ac;">
Your one-time sign-in code for the Nuggets host portal is below. It expires in 10 minutes.
</p>
<div style="margin:0 0 24px;padding:20px 24px;background:#1a1a15;border:1px solid #2a2518;border-radius:4px;text-align:center;">
<span style="font-family:monospace;font-size:36px;font-weight:700;letter-spacing:8px;color:#c9a84c;">${escapeHtml(code)}</span>
</div>
<p style="margin:0;font-family:Georgia,serif;font-size:14px;color:#7a7060;">If you did not request this code, you can safely ignore this email.</p>
`
try {
const { error } = await resend.emails.send({
from,
to: [to],
subject: 'Your Nuggets Portal Sign-In Code',
text: `Hi ${name},\n\nYour one-time sign-in code is: ${code}\n\nIt expires in 10 minutes. If you did not request this, ignore this email.`,
html: buildBrandedEmailHtml({
title: 'Sign-In Code',
eyebrow: 'Nuggets',
bodyHtml,
footerHtml: '<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;color:#7a7060;">You received this because a sign-in was attempted on the Nuggets podcast host portal.</p>',
}),
})
if (error) console.error('[nuggets-otp] send error:', error)
} catch (err) {
console.error('[nuggets-otp] send exception:', err)
}
}
+33
View File
@@ -0,0 +1,33 @@
// Episode scripts are filed by season + episode number ("2-1"), because each book of the Bible
// is its own podcast season and episode numbers can restart in every season. A script with no
// season keeps its plain episode number as its key.
export function scriptKey(season, episodeNumber) {
const ep = String(episodeNumber ?? '').trim()
const s = String(season ?? '').trim()
return /^\d+$/.test(s) ? `${s}-${ep}` : ep
}
// The episode number a stored script belongs to (new entries record it; legacy ones are keyed by it).
export function scriptEpisodeNumber(key, entry) {
if (entry && typeof entry.episodeNumber === 'string' && entry.episodeNumber) return entry.episodeNumber
const parts = String(key).split('-')
return parts[parts.length - 1]
}
// One-time move of legacy scripts ({ "7": { season: "1" } }) to season-episode keys ({ "1-7": ... }).
// Scripts without a season stay under their plain episode number. Never overwrites an existing key.
export function migrateScriptKeys(scripts) {
const next = {}
let changed = false
for (const [key, entry] of Object.entries(scripts ?? {})) {
if (!entry || typeof entry !== 'object') continue
const episodeNumber = scriptEpisodeNumber(key, entry)
const wanted = scriptKey(entry.season, episodeNumber)
const targetKey = wanted !== key && !(wanted in scripts) && !(wanted in next) ? wanted : key
const withEpisode = entry.episodeNumber === episodeNumber ? entry : { ...entry, episodeNumber }
if (targetKey !== key || withEpisode !== entry) changed = true
next[targetKey] = withEpisode
}
return { scripts: next, changed }
}
+89
View File
@@ -0,0 +1,89 @@
import { fetchAllEpisodes } from './routes/episodes.js'
import { loadPodcastWorkflow, laneSeasonOf } from './routes/podcast-workflow.js'
// The Spotify (Anchor) RSS feed only lists episodes that are live, so a season + episode number
// showing up there means the episode has been published. That drives two things on the public site:
// - highlights marked "hold until released" stay hidden until their episode is in the feed
// - highlights and study lessons with no audio of their own pick it up from the matching feed episode
const SNAPSHOT_TTL_MS = 5 * 60 * 1000
let snapshot = { items: [], at: 0 }
let inflight = null
function refreshSnapshot() {
if (!inflight) {
inflight = fetchAllEpisodes()
.then(items => { snapshot = { items, at: Date.now() } })
// Keep serving the last good copy; retry on the next TTL window
.catch(() => { snapshot = { items: snapshot.items, at: Date.now() } })
.finally(() => { inflight = null })
}
return inflight
}
export async function getFeedIndex() {
if (!snapshot.at) await refreshSnapshot()
else if (Date.now() - snapshot.at > SNAPSHOT_TTL_MS) refreshSnapshot()
const index = new Map()
for (const item of snapshot.items) {
if (item.season && /^\d+$/.test(item.episode)) index.set(`${item.season}-${item.episode}`, item)
}
return index
}
export function warmFeedSnapshot() {
return refreshSnapshot()
}
// Returns a copy of the published site content with the feed applied. Run it before
// filterSiteContentByReleaseDate so audio is never added to a lesson that is still locked.
export async function applyFeedRelease(content) {
if (!content || typeof content !== 'object' || Array.isArray(content)) return content
const links = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
const studies = Array.isArray(content.studies) ? content.studies : []
if (links.length === 0 && studies.length === 0) return content
const [index, wf] = await Promise.all([getFeedIndex(), loadPodcastWorkflow()])
const seasonA = laneSeasonOf('A', wf, content)
const seasonB = laneSeasonOf('B', wf, content)
const nextLinks = links.flatMap(link => {
// General highlights aren't tied to a series, so there's no season to match against
if (!link || link.lane === 'general') return [link]
const season = link.lane === 'second' ? seasonB : seasonA
const item = season && link.episodeNumber ? index.get(`${season}-${String(link.episodeNumber).trim()}`) : null
if (!item) return link.holdUntilReleased ? [] : [link]
// Highlights with no audio of their own pick it up from the matching feed episode
return [{
...link,
url: link.url || item.link || '',
embedUrl: link.embedUrl?.trim() ? link.embedUrl : (item.audioUrl || link.embedUrl),
}]
})
const studyLane = study => {
if (study?.lane === 'A' || study?.lane === 'B') return study.lane
if (study?.slug && study.slug === content.seriesStudySlug) return 'A'
if (study?.slug && study.slug === content.secondSeries?.studySlug) return 'B'
return ''
}
const nextStudies = studies.map(study => {
const lane = studyLane(study)
const season = lane === 'A' ? seasonA : lane === 'B' ? seasonB : ''
if (!season || !Array.isArray(study.sections)) return study
return {
...study,
sections: study.sections.map(section => {
if (!section || section.audioEmbedUrl?.trim() || !section.episodeNumber) return section
const item = index.get(`${season}-${String(section.episodeNumber).trim()}`)
return item?.audioUrl ? { ...section, audioEmbedUrl: item.audioUrl } : section
}),
}
})
return { ...content, podcastFeaturedLinks: nextLinks, studies: nextStudies }
}
+93 -1
View File
@@ -134,14 +134,26 @@ export function sanitizeFeaturedLinks(value) {
.slice(0, 30) .slice(0, 30)
: [] : []
const sanitizeStringArray = (val, limit) =>
Array.isArray(val)
? val.filter(s => typeof s === 'string').map(s => s.trim()).filter(Boolean).slice(0, limit)
: []
return { return {
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
lane: item.lane === 'second' || item.lane === 'general' ? item.lane : 'primary',
holdUntilReleased: item.holdUntilReleased === true,
title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '', title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '',
episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '', episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '',
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '', summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
url: sanitizeUrl(item.url), url: sanitizeUrl(item.url),
embedUrl: sanitizeUrl(item.embedUrl), embedUrl: sanitizeUrl(item.embedUrl),
showNotes: typeof item.showNotes === 'string' ? item.showNotes.trim().slice(0, 10000) : '', showNotes: typeof item.showNotes === 'string' ? item.showNotes.trim().slice(0, 10000) : '',
showNotesSummary: typeof item.showNotesSummary === 'string' ? item.showNotesSummary.trim().slice(0, 1000) : '',
showNotesThemes: sanitizeStringArray(item.showNotesThemes, 20),
showNotesScripture: sanitizeStringArray(item.showNotesScripture, 30),
showNotesQuotes: sanitizeStringArray(item.showNotesQuotes, 20),
showNotesResources: sanitizeStringArray(item.showNotesResources, 30),
discussionQuestions, discussionQuestions,
} }
}) })
@@ -152,6 +164,11 @@ export function sanitizeFeaturedLinks(value) {
item.url || item.url ||
item.embedUrl || item.embedUrl ||
item.showNotes || item.showNotes ||
item.showNotesSummary ||
item.showNotesThemes.length > 0 ||
item.showNotesScripture.length > 0 ||
item.showNotesQuotes.length > 0 ||
item.showNotesResources.length > 0 ||
item.discussionQuestions.length > 0, item.discussionQuestions.length > 0,
) )
} }
@@ -253,6 +270,7 @@ function sanitizeColossiansStudySections(value) {
chapter: Number.isInteger(item.chapter) && item.chapter >= 1 && item.chapter <= 999 ? item.chapter : 1, chapter: Number.isInteger(item.chapter) && item.chapter >= 1 && item.chapter <= 999 ? item.chapter : 1,
reference: typeof item.reference === 'string' ? item.reference.trim().slice(0, 40) : '', reference: typeof item.reference === 'string' ? item.reference.trim().slice(0, 40) : '',
title: typeof item.title === 'string' ? item.title.trim().slice(0, 160) : '', title: typeof item.title === 'string' ? item.title.trim().slice(0, 160) : '',
episodeNumber: typeof item.episodeNumber === 'string' || typeof item.episodeNumber === 'number' ? String(item.episodeNumber).trim().slice(0, 20) : '',
audioEmbedUrl: sanitizeUrl(item.audioEmbedUrl), audioEmbedUrl: sanitizeUrl(item.audioEmbedUrl),
passageText: typeof item.passageText === 'string' ? item.passageText.trim().slice(0, 12000) : '', passageText: typeof item.passageText === 'string' ? item.passageText.trim().slice(0, 12000) : '',
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '', summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
@@ -274,6 +292,45 @@ function sanitizeColossiansStudySections(value) {
.filter(item => item.title || item.summary || item.commentary || item.greekNotes.length > 0 || item.studyQuestions.length > 0 || item.passageText || item.checkpointPrompt || item.checkpointQuestions.length > 0) .filter(item => item.title || item.summary || item.commentary || item.greekNotes.length > 0 || item.studyQuestions.length > 0 || item.passageText || item.checkpointPrompt || item.checkpointQuestions.length > 0)
} }
function sanitizeIsoDate(value) {
if (typeof value !== 'string' || !value.trim()) return ''
const time = Date.parse(value)
return Number.isFinite(time) ? new Date(time).toISOString() : ''
}
function sanitizeStudyGuide(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
const str = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '')
const title = str(value.title, 140)
if (!title) return undefined
return {
title,
description: str(value.description, 4000),
downloadUrl: sanitizeUrl(value.downloadUrl),
amazonUrl: sanitizeUrl(value.amazonUrl),
amazonLabel: str(value.amazonLabel, 80),
appleBooksUrl: sanitizeUrl(value.appleBooksUrl),
appleBooksLabel: str(value.appleBooksLabel, 80),
}
}
function sanitizeSecondSeries(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
const str = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '')
const season = Number.isInteger(value.season) && value.season >= 1 && value.season <= 999 ? value.season : undefined
return {
label: str(value.label, 80) || 'Now Playing',
title: str(value.title, 160),
description: str(value.description, 800),
imageUrl: str(value.imageUrl, 500),
listenUrl: str(value.listenUrl, 500),
listenBtnLabel: str(value.listenBtnLabel, 80),
studySlug: str(value.studySlug, 120).toLowerCase().replace(/[^a-z0-9-]/g, '-'),
...(season ? { season } : {}),
...(sanitizeStudyGuide(value.guide) ? { guide: sanitizeStudyGuide(value.guide) } : {}),
}
}
function sanitizeStudies(value) { function sanitizeStudies(value) {
const source = Array.isArray(value) ? value : [] const source = Array.isArray(value) ? value : []
return source return source
@@ -284,10 +341,14 @@ function sanitizeStudies(value) {
title: typeof item.title === 'string' ? item.title.trim().slice(0, 160) : '', title: typeof item.title === 'string' ? item.title.trim().slice(0, 160) : '',
description: typeof item.description === 'string' ? item.description.trim().slice(0, 800) : '', description: typeof item.description === 'string' ? item.description.trim().slice(0, 800) : '',
homepageEyebrow: typeof item.homepageEyebrow === 'string' ? item.homepageEyebrow.trim().slice(0, 80) : '', homepageEyebrow: typeof item.homepageEyebrow === 'string' ? item.homepageEyebrow.trim().slice(0, 80) : '',
imageUrl: sanitizeUrl(item.imageUrl) || '',
showOnHomepage: item.showOnHomepage === true, showOnHomepage: item.showOnHomepage === true,
showNewTag: item.showNewTag === true, showNewTag: item.showNewTag === true,
newTagLabel: typeof item.newTagLabel === 'string' ? item.newTagLabel.trim().slice(0, 24) : '', newTagLabel: typeof item.newTagLabel === 'string' ? item.newTagLabel.trim().slice(0, 24) : '',
status: item.status === 'planned' ? 'planned' : 'active', guideSource: typeof item.guideSource === 'string' && /^(none|primary|second|book:[A-Za-z0-9_-]{1,80})$/.test(item.guideSource.trim()) ? item.guideSource.trim() : '',
status: item.status === 'planned' ? 'planned' : (item.status === 'complete' ? 'complete' : 'active'),
lane: item.lane === 'A' || item.lane === 'B' ? item.lane : '',
startsAt: sanitizeIsoDate(item.startsAt),
difficulty: item.difficulty === 'advanced' ? 'advanced' : (item.difficulty === 'intermediate' ? 'intermediate' : 'beginner'), difficulty: item.difficulty === 'advanced' ? 'advanced' : (item.difficulty === 'intermediate' ? 'intermediate' : 'beginner'),
estimatedHours: Number.isFinite(Number(item.estimatedHours)) ? Math.max(1, Math.min(500, Number(item.estimatedHours))) : 8, estimatedHours: Number.isFinite(Number(item.estimatedHours)) ? Math.max(1, Math.min(500, Number(item.estimatedHours))) : 8,
completionBadge: typeof item.completionBadge === 'string' ? item.completionBadge.trim().slice(0, 120) : '', completionBadge: typeof item.completionBadge === 'string' ? item.completionBadge.trim().slice(0, 120) : '',
@@ -330,6 +391,32 @@ function sanitizeArchivedSeries(value) {
) )
} }
function sanitizeNuggets(raw) {
const n = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
const hosts = Array.isArray(n.hosts)
? n.hosts
.filter(h => h && typeof h === 'object' && typeof h.id === 'string' && h.id.trim())
.map(h => ({
id: h.id.trim(),
name: typeof h.name === 'string' ? h.name.trim().slice(0, 120) : '',
role: typeof h.role === 'string' ? h.role.trim().slice(0, 120) : '',
bio: typeof h.bio === 'string' ? h.bio.trim().slice(0, 4000) : '',
imageUrl: sanitizeUrl(h.imageUrl) || '',
archived: Boolean(h.archived),
}))
: []
return {
title: typeof n.title === 'string' ? n.title.trim().slice(0, 120) : '',
tagline: typeof n.tagline === 'string' ? n.tagline.trim().slice(0, 240) : '',
description: typeof n.description === 'string' ? n.description.trim().slice(0, 600) : '',
podcastArtUrl: sanitizeUrl(n.podcastArtUrl) || '',
rssFeedUrl: sanitizeUrl(n.rssFeedUrl) || '',
spotifyUrl: sanitizeUrl(n.spotifyUrl) || '',
appleUrl: sanitizeUrl(n.appleUrl) || '',
hosts,
}
}
export function sanitizeSiteContent(siteContent) { export function sanitizeSiteContent(siteContent) {
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return {} if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return {}
@@ -342,9 +429,14 @@ export function sanitizeSiteContent(siteContent) {
customBlocks: sanitizeCustomBlocks(siteContent.customBlocks), customBlocks: sanitizeCustomBlocks(siteContent.customBlocks),
archivedSeries: sanitizeArchivedSeries(siteContent.archivedSeries), archivedSeries: sanitizeArchivedSeries(siteContent.archivedSeries),
studies: sanitizeStudies(siteContent.studies), studies: sanitizeStudies(siteContent.studies),
secondSeries: sanitizeSecondSeries(siteContent.secondSeries),
finishedBooks: Array.isArray(siteContent.finishedBooks)
? siteContent.finishedBooks.map(book => (book && typeof book === 'object' ? { ...book, guide: sanitizeStudyGuide(book.guide) } : book))
: siteContent.finishedBooks,
colossiansStudySections: sanitizeColossiansStudySections(siteContent.colossiansStudySections), colossiansStudySections: sanitizeColossiansStudySections(siteContent.colossiansStudySections),
redirects: sanitizeRedirectRules(siteContent.redirects), redirects: sanitizeRedirectRules(siteContent.redirects),
podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS), podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS),
nuggets: sanitizeNuggets(siteContent.nuggets),
episodesSeoIntro: typeof siteContent.episodesSeoIntro === 'string' && siteContent.episodesSeoIntro.trim() ? siteContent.episodesSeoIntro.trim().slice(0, 600) : '', episodesSeoIntro: typeof siteContent.episodesSeoIntro === 'string' && siteContent.episodesSeoIntro.trim() ? siteContent.episodesSeoIntro.trim().slice(0, 600) : '',
welcomeEmailSpotifyBtnLabel: typeof siteContent.welcomeEmailSpotifyBtnLabel === 'string' ? siteContent.welcomeEmailSpotifyBtnLabel.trim().slice(0, 80) : '', welcomeEmailSpotifyBtnLabel: typeof siteContent.welcomeEmailSpotifyBtnLabel === 'string' ? siteContent.welcomeEmailSpotifyBtnLabel.trim().slice(0, 80) : '',
welcomeEmailAppleBtnLabel: typeof siteContent.welcomeEmailAppleBtnLabel === 'string' ? siteContent.welcomeEmailAppleBtnLabel.trim().slice(0, 80) : '', welcomeEmailAppleBtnLabel: typeof siteContent.welcomeEmailAppleBtnLabel === 'string' ? siteContent.welcomeEmailAppleBtnLabel.trim().slice(0, 80) : '',
+94
View File
@@ -0,0 +1,94 @@
import { readFile } from 'node:fs/promises'
import { writeFile } from './atomic-write.js'
import { sendNuggetsUploadNotification } from './email.js'
import {
SEAFILE_URL,
SEAFILE_TOKEN,
SEAFILE_NUGGETS_REPO_ID,
SEAFILE_EPISODE_UPLOAD_DIR,
SEAFILE_EPISODE_INFO_DIR,
SEAFILE_POLL_INTERVAL_MS,
NUGGETS_SEAFILE_SEEN_FILE,
NUGGETS_NOTIFY_EMAIL,
} from './config.js'
async function loadSeen() {
try {
return JSON.parse(await readFile(NUGGETS_SEAFILE_SEEN_FILE, 'utf8'))
} catch {
return { files: [] }
}
}
async function saveSeen(data) {
await writeFile(NUGGETS_SEAFILE_SEEN_FILE, JSON.stringify(data, null, 2), 'utf8')
}
// Called by the upload route after a successful portal upload so the poller
// doesn't also fire a notification for the same file.
export async function markSeafileFileSeen(filename) {
const data = await loadSeen()
if (!data.files.includes(filename)) {
data.files.push(filename)
await saveSeen(data)
}
}
async function listSeafileDir(dir) {
if (!SEAFILE_URL || !SEAFILE_TOKEN || !SEAFILE_NUGGETS_REPO_ID) return []
try {
const url = `${SEAFILE_URL}/api2/repos/${SEAFILE_NUGGETS_REPO_ID}/dir/?p=${encodeURIComponent(dir)}`
const res = await fetch(url, {
headers: { Authorization: `Token ${SEAFILE_TOKEN}` },
signal: AbortSignal.timeout(10_000),
})
if (!res.ok) return []
const data = await res.json()
return (data.dirent ?? []).filter(e => e.type === 'file').map(e => e.name)
} catch {
return []
}
}
async function poll() {
if (!SEAFILE_URL || !SEAFILE_TOKEN || !SEAFILE_NUGGETS_REPO_ID) return
const [uploadFiles, seen] = await Promise.all([
listSeafileDir(SEAFILE_EPISODE_UPLOAD_DIR),
loadSeen(),
])
const seenSet = new Set(seen.files)
const newFiles = uploadFiles.filter(f => !seenSet.has(f))
if (newFiles.length === 0) return
for (const filename of newFiles) {
console.log(`[nuggets-watcher] new direct upload detected: ${filename}`)
await sendNuggetsUploadNotification({
to: NUGGETS_NOTIFY_EMAIL,
source: 'direct',
filename,
})
seenSet.add(filename)
}
seen.files = [...seenSet]
await saveSeen(seen)
}
export function startNuggetsSeafileWatcher() {
if (!SEAFILE_URL || !SEAFILE_TOKEN || !SEAFILE_NUGGETS_REPO_ID) {
console.log('[nuggets-watcher] Seafile not configured — watcher disabled.')
return
}
// Initial poll after 2 min (let the server settle), then every 15 min
setTimeout(() => {
poll().catch(err => console.error('[nuggets-watcher] poll error:', err))
setInterval(() => {
poll().catch(err => console.error('[nuggets-watcher] poll error:', err))
}, SEAFILE_POLL_INTERVAL_MS)
}, 2 * 60 * 1000)
console.log(`[nuggets-watcher] started — polling every ${SEAFILE_POLL_INTERVAL_MS / 60000} min`)
}
+53
View File
@@ -0,0 +1,53 @@
import { S3Client, PutObjectCommand, DeleteObjectCommand, CopyObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'
import { R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET, R2_PUBLIC_URL } from './config.js'
function makeClient() {
return new S3Client({
region: 'auto',
endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: { accessKeyId: R2_ACCESS_KEY_ID, secretAccessKey: R2_SECRET_ACCESS_KEY },
})
}
export function isR2Configured() {
return Boolean(R2_ACCOUNT_ID && R2_ACCESS_KEY_ID && R2_SECRET_ACCESS_KEY && R2_BUCKET && R2_PUBLIC_URL)
}
export async function uploadToR2(buffer, key, contentType) {
await makeClient().send(new PutObjectCommand({
Bucket: R2_BUCKET,
Key: key,
Body: buffer,
ContentType: contentType,
}))
return `${R2_PUBLIC_URL}/${key}`
}
export async function deleteFromR2(key) {
await makeClient().send(new DeleteObjectCommand({ Bucket: R2_BUCKET, Key: key }))
}
export async function copyInR2(fromKey, toKey) {
await makeClient().send(new CopyObjectCommand({
Bucket: R2_BUCKET,
CopySource: `${R2_BUCKET}/${fromKey.split('/').map(encodeURIComponent).join('/')}`,
Key: toKey,
}))
return `${R2_PUBLIC_URL}/${toKey}`
}
export async function listR2Objects(prefix) {
const objects = []
let continuationToken
const client = makeClient()
do {
const resp = await client.send(new ListObjectsV2Command({
Bucket: R2_BUCKET,
Prefix: prefix,
ContinuationToken: continuationToken,
}))
for (const obj of resp.Contents ?? []) objects.push(obj)
continuationToken = resp.IsTruncated ? resp.NextContinuationToken : undefined
} while (continuationToken)
return objects
}
-111
View File
@@ -1,111 +0,0 @@
import { Resend } from 'resend'
import { state } from './state.js'
import { queuePodcastChecklistWrite, queueCalendarEventsWrite } from './data.js'
import { DEFAULT_RESEND_FROM, DEFAULT_RESEND_TO } from './config.js'
function toDateKey(date) {
return date.toISOString().slice(0, 10)
}
export function startReminderScheduler() {
checkReminders()
setInterval(checkReminders, 60 * 60 * 1000)
}
async function checkReminders() {
const todayKey = toDateKey(new Date())
// Episodes
const episodes = state.podcastChecklist?.episodes
if (Array.isArray(episodes)) {
let changed = false
for (const ep of episodes) {
if (!ep.datePublished || !(ep.reminderDays > 0) || ep.reminderSentAt) continue
const publish = new Date(ep.datePublished + 'T12:00:00Z')
if (isNaN(publish.getTime())) continue
const reminderDate = new Date(publish)
reminderDate.setUTCDate(reminderDate.getUTCDate() - ep.reminderDays)
if (todayKey === toDateKey(reminderDate)) {
const sent = await sendReminderEmail({
label: ep.episodeNumber ? `Episode ${ep.episodeNumber}` : 'Episode',
title: ep.title || 'Untitled',
series: ep.series,
date: ep.datePublished,
reminderDays: ep.reminderDays,
type: 'episode',
})
if (sent) { ep.reminderSentAt = new Date().toISOString(); changed = true }
}
}
if (changed) queuePodcastChecklistWrite()
}
// Calendar events
const events = state.calendarEvents
if (Array.isArray(events)) {
let changed = false
for (const ev of events) {
if (!ev.date || !(ev.reminderDays > 0) || ev.reminderSentAt) continue
const publish = new Date(ev.date + 'T12:00:00Z')
if (isNaN(publish.getTime())) continue
const reminderDate = new Date(publish)
reminderDate.setUTCDate(reminderDate.getUTCDate() - ev.reminderDays)
if (todayKey === toDateKey(reminderDate)) {
const sent = await sendReminderEmail({
label: ev.type.charAt(0).toUpperCase() + ev.type.slice(1),
title: ev.title,
series: null,
date: ev.date,
reminderDays: ev.reminderDays,
type: ev.type,
})
if (sent) { ev.reminderSentAt = new Date().toISOString(); changed = true }
}
}
if (changed) queueCalendarEventsWrite()
}
}
const TYPE_ICONS = { episode: '📅', general: '📌', recording: '🎙️', social: '📱', task: '✅' }
async function sendReminderEmail({ label, title, series, date, reminderDays, type }) {
if (!process.env.RESEND_API_KEY) return false
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
const to = process.env.RESEND_TO ?? DEFAULT_RESEND_TO
const eventDate = new Date(date + 'T12:00:00Z')
const msLeft = eventDate.getTime() - Date.now()
const daysLeft = Math.max(0, Math.ceil(msLeft / (1000 * 60 * 60 * 24)))
const seriesStr = series ? ` (${series})` : ''
const daysText = daysLeft === 0 ? 'today' : daysLeft === 1 ? 'in 1 day' : `in ${daysLeft} days`
const icon = TYPE_ICONS[type] ?? '📅'
const subject = `Reminder: ${label}: ${title} — ${daysText}`
const html = `
<div style="font-family:system-ui,sans-serif;max-width:540px;margin:0 auto;color:#222">
<h2 style="color:#c8860a;margin-bottom:4px">${icon} Calendar Reminder</h2>
<p style="font-size:1.1rem;margin-bottom:16px">
<strong>${label}: ${title}</strong>${seriesStr}<br>
<span style="color:#555">Scheduled for <strong>${date}</strong> — ${daysText}</span>
</p>
<hr style="border:none;border-top:1px solid #eee;margin:16px 0">
<p style="color:#777;font-size:0.85rem">
This reminder was set ${reminderDays} day${reminderDays === 1 ? '' : 's'} before the date.
To change or remove it, open the Calendar in your admin panel.
</p>
</div>
`
const { error } = await resend.emails.send({ from, to, subject, html })
if (error) { console.error('[reminder] send error:', error); return false }
console.log(`[reminder] sent for "${title}" (${date})`)
return true
} catch (err) {
console.error('[reminder] send exception:', err)
return false
}
}
+175 -32
View File
@@ -1,14 +1,16 @@
import { mkdir, stat, unlink, writeFile } from 'node:fs/promises' import { mkdir, rename, stat, unlink, writeFile } from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { inferImageExtensionFromDataUrl, normalizeAssetBaseName } from '../helpers.js' import { inferImageExtensionFromDataUrl, normalizeAssetBaseName } from '../helpers.js'
import { requireAdminAuth } from '../auth.js' import { requireAdminAuth } from '../auth.js'
import { UPLOADS_DIR } from '../config.js' import { UPLOADS_DIR } from '../config.js'
import { isR2Configured, uploadToR2, deleteFromR2, copyInR2 } from '../r2.js'
import { state } from '../state.js' import { state } from '../state.js'
import { import {
listUploadedAssets, listUploadedAssets,
readUploadsMetadata, readUploadsMetadata,
writeUploadsMetadata, writeUploadsMetadata,
getUserNotesFilePath, getUserNotesFilePath,
loadUserProgress,
queueStudyUsersWrite, queueStudyUsersWrite,
} from '../data.js' } from '../data.js'
import { import {
@@ -16,13 +18,27 @@ import {
getStudyCatalog, getStudyCatalog,
normalizeStudySlug, normalizeStudySlug,
isEnrollableStudySlug, isEnrollableStudySlug,
makeUnsubUrl,
invalidateAllStudySessions,
} from '../study-helpers.js' } from '../study-helpers.js'
import { sendStudyReengagementEmail, sendStudyPasswordChangedEmail } from '../email.js'
const ALLOWED_FOLDERS = ['uploads', 'images', 'guides', 'finished']
function isSafeFilename(filename) {
return typeof filename === 'string' && filename.length > 0 && !filename.includes('/') && !filename.includes('\\') && !filename.includes('..')
}
export function register(app) { export function register(app) {
app.get('/api/admin-assets', requireAdminAuth, async (_req, res) => { app.get('/api/admin-assets', requireAdminAuth, async (_req, res) => {
try { try {
const assets = await listUploadedAssets() const assets = await listUploadedAssets()
res.json({ assets }) const haystack = JSON.stringify(state.cachedDraftSiteContent ?? {}) + JSON.stringify(state.cachedSiteContent ?? {})
const withUsage = assets.map(asset => ({
...asset,
usedIn: asset.url ? haystack.split(asset.url).length - 1 : 0,
}))
res.json({ assets: withUsage })
} catch { } catch {
res.status(500).json({ message: 'Could not list uploaded assets.' }) res.status(500).json({ message: 'Could not list uploaded assets.' })
} }
@@ -32,8 +48,13 @@ export function register(app) {
try { try {
const filename = typeof req.body?.filename === 'string' ? req.body.filename : '' const filename = typeof req.body?.filename === 'string' ? req.body.filename : ''
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : '' const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
const rawFolder = typeof req.body?.folder === 'string' ? req.body.folder : 'uploads'
const rawStorage = typeof req.body?.storage === 'string' ? req.body.storage : 'r2'
const ext = inferImageExtensionFromDataUrl(dataUrl) const ext = inferImageExtensionFromDataUrl(dataUrl)
const folder = ALLOWED_FOLDERS.includes(rawFolder) ? rawFolder : 'uploads'
const useLocal = rawStorage === 'local' || !isR2Configured()
if (!ext) { if (!ext) {
res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, GIF, PDF, DOC, or DOCX data URL.' }) res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, GIF, PDF, DOC, or DOCX data URL.' })
return return
@@ -49,14 +70,23 @@ export function register(app) {
const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, '')) const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, ''))
const finalName = `${baseName}-${Date.now()}${ext}` const finalName = `${baseName}-${Date.now()}${ext}`
await mkdir(UPLOADS_DIR, { recursive: true }) let url
await writeFile(path.join(UPLOADS_DIR, finalName), buffer) if (useLocal) {
await mkdir(UPLOADS_DIR, { recursive: true })
await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
url = `/uploads/${finalName}`
} else {
const contentType = dataUrl.match(/^data:([^;]+);base64,/)?.[1] ?? 'application/octet-stream'
url = await uploadToR2(buffer, `${folder}/${finalName}`, contentType)
}
const metadata = await readUploadsMetadata() const metadata = await readUploadsMetadata()
metadata[finalName] = [] metadata[finalName] = { tags: [], url, folder: useLocal ? 'local' : folder }
await writeUploadsMetadata(metadata) await writeUploadsMetadata(metadata)
res.json({ ok: true, asset: { filename: finalName, url: `/uploads/${finalName}` } }) res.json({ ok: true, asset: { filename: finalName, url, folder: useLocal ? 'local' : folder, storage: useLocal ? 'local' : 'r2' } })
} catch { } catch (err) {
console.error('[admin-assets] upload failed:', err)
res.status(500).json({ message: 'Upload failed.' }) res.status(500).json({ message: 'Upload failed.' })
} }
}) })
@@ -64,43 +94,105 @@ export function register(app) {
app.patch('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => { app.patch('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => {
try { try {
const { filename } = req.params const { filename } = req.params
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) { if (!isSafeFilename(filename)) {
res.status(400).json({ message: 'Invalid filename.' }) res.status(400).json({ message: 'Invalid filename.' })
return return
} }
const tags = Array.isArray(req.body?.tags)
? req.body.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean)
: []
const filePath = path.join(UPLOADS_DIR, filename)
await stat(filePath)
const metadata = await readUploadsMetadata() const metadata = await readUploadsMetadata()
metadata[filename] = tags const current = (await listUploadedAssets()).find(a => a.filename === filename)
if (!current) {
res.status(404).json({ message: 'Asset not found.' })
return
}
const entry = metadata[filename]
let tags = Array.isArray(entry)
? entry
: Array.isArray(entry?.tags) ? entry.tags : []
if (Array.isArray(req.body?.tags)) {
tags = req.body.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean)
}
let newFilename = filename
let newUrl = current.url
let newFolder = current.folder
const ext = path.extname(filename)
const wantsRename = typeof req.body?.newName === 'string' && req.body.newName.trim()
const wantsMove = typeof req.body?.folder === 'string' && req.body.folder !== current.folder
if (wantsRename) {
const base = normalizeAssetBaseName(req.body.newName.trim().replace(/\.[a-z0-9]+$/i, ''))
newFilename = `${base}${ext}`
}
if (wantsMove) {
if (current.storage !== 'r2' || !ALLOWED_FOLDERS.includes(req.body.folder)) {
res.status(400).json({ message: 'Only R2 assets can be moved, to uploads, images, guides, or finished.' })
return
}
newFolder = req.body.folder
}
if (newFilename !== filename || newFolder !== current.folder) {
if (newFilename !== filename) {
const taken = (await listUploadedAssets()).some(a => a.filename === newFilename && a.folder === newFolder)
if (taken) {
res.status(409).json({ message: `A file named ${newFilename} already exists there.` })
return
}
}
if (current.storage === 'r2') {
const newKey = `${newFolder}/${newFilename}`
newUrl = await copyInR2(current.key, newKey)
await deleteFromR2(current.key)
} else {
await rename(path.join(UPLOADS_DIR, filename), path.join(UPLOADS_DIR, newFilename))
newUrl = `/uploads/${newFilename}`
}
delete metadata[filename]
}
metadata[newFilename] = { tags, url: newUrl, folder: current.storage === 'r2' ? newFolder : 'local' }
await writeUploadsMetadata(metadata) await writeUploadsMetadata(metadata)
res.json({ ok: true, tags }) res.json({ ok: true, tags, filename: newFilename, url: newUrl, folder: newFolder, oldUrl: current.url })
} catch { } catch (err) {
res.status(404).json({ message: 'Asset not found.' }) console.error('[admin-assets] update failed:', err)
res.status(500).json({ message: 'Could not update asset.' })
} }
}) })
app.delete('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => { app.delete('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => {
try { try {
const { filename } = req.params const { filename } = req.params
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) { if (!isSafeFilename(filename)) {
res.status(400).json({ message: 'Invalid filename.' }) res.status(400).json({ message: 'Invalid filename.' })
return return
} }
await unlink(path.join(UPLOADS_DIR, filename)) // Optional ?folder= disambiguates identical filenames in different R2 folders.
const folder = typeof req.query.folder === 'string' ? req.query.folder : null
const matches = (await listUploadedAssets()).filter(a => a.filename === filename && (!folder || a.folder === folder))
const target = matches[0]
if (!target) {
res.status(404).json({ message: 'Asset not found.' })
return
}
if (target.storage === 'r2') {
await deleteFromR2(target.key)
} else {
await unlink(path.join(UPLOADS_DIR, filename)).catch(() => {})
}
const metadata = await readUploadsMetadata() const metadata = await readUploadsMetadata()
delete metadata[filename] delete metadata[filename]
await writeUploadsMetadata(metadata) await writeUploadsMetadata(metadata)
res.json({ ok: true }) res.json({ ok: true })
} catch { } catch (err) {
res.status(404).json({ message: 'Asset not found.' }) console.error('[admin-assets] delete failed:', err)
res.status(500).json({ message: 'Delete failed.' })
} }
}) })
@@ -117,9 +209,19 @@ export function register(app) {
noteCount = Object.values(notes).filter(n => typeof n === 'string' && n.trim()).length noteCount = Object.values(notes).filter(n => typeof n === 'string' && n.trim()).length
} catch { /* ignore */ } } catch { /* ignore */ }
let progressByStudy = {}
try {
const prog = await loadUserProgress(user.id)
for (const [slug, data] of Object.entries(prog.byStudy)) {
progressByStudy[slug] = { completedLessons: (data.completedSectionIds ?? []).length }
}
} catch { /* ignore */ }
const enrolledStudies = (user.enrolledStudySlugs ?? []).map(slug => { const enrolledStudies = (user.enrolledStudySlugs ?? []).map(slug => {
const study = catalog.find(s => s.slug === slug) const study = catalog.find(s => s.slug === slug)
return study ? { slug, title: study.title } : { slug, title: slug } return study
? { slug, title: study.title, completedLessons: progressByStudy[slug]?.completedLessons ?? 0 }
: { slug, title: slug, completedLessons: 0 }
}) })
return { return {
@@ -132,30 +234,73 @@ export function register(app) {
noteCount, noteCount,
subscribeNewsletter: user.subscribeNewsletter !== false, subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true, studyRemindersEnabled: user.studyRemindersEnabled === true,
reminderFrequency: user.reminderFrequency ?? 'immediate',
remindersPausedUntil: user.remindersPausedUntil ?? null,
currentStreak: user.currentStreak ?? 0,
longestStreak: user.longestStreak ?? 0,
reengagementSentAt: user.reengagementSentAt ?? {},
adminShortcut: user.adminShortcut === true,
} }
})) }))
res.json({ users }) res.json({ users })
}) })
app.post('/api/admin/study-users/:id/send-reengagement', requireAdminAuth, async (req, res) => {
const user = state.studyUsers.find(u => u.id === req.params.id)
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
const tier = req.body?.tier
if (tier !== 't1' && tier !== 't2' && tier !== 't3') {
res.status(400).json({ message: 'tier must be t1, t2, or t3.' }); return
}
const studies = Array.isArray(state.cachedSiteContent?.studies) ? state.cachedSiteContent.studies : []
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
const enrolledStudies = enrolledStudySlugs
.map(slug => studies.find(s => normalizeStudySlug(s?.slug) === slug))
.filter(Boolean)
if (enrolledStudies.length === 0) { res.status(400).json({ message: 'User has no enrolled studies.' }); return }
const firstStudy = enrolledStudies[0]
const studyTitle = enrolledStudies.length === 1 ? firstStudy.title : null
const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
const studyUrl = enrolledStudies.length === 1 ? `${base}/study/${firstStudy.slug}` : `${base}/study`
const unsubUrl = makeUnsubUrl(user.id)
try {
await sendStudyReengagementEmail(user.username, user.displayName || user.username, studyTitle, studyUrl, tier, unsubUrl)
res.json({ ok: true })
} catch (err) {
console.error(`[admin] manual reengagement send failed for ${user.username}:`, err)
res.status(500).json({ message: 'Failed to send email.' })
}
})
app.patch('/api/admin/study-users/:id', requireAdminAuth, (req, res) => { app.patch('/api/admin/study-users/:id', requireAdminAuth, (req, res) => {
const user = state.studyUsers.find(u => u.id === req.params.id) const user = state.studyUsers.find(u => u.id === req.params.id)
if (!user) { res.status(404).json({ message: 'User not found.' }); return } if (!user) { res.status(404).json({ message: 'User not found.' }); return }
const { displayName, newPassword, addEnrollment, removeEnrollment } = req.body ?? {} const { displayName, newPassword, addEnrollment, removeEnrollment, adminShortcut } = req.body ?? {}
if (typeof displayName === 'string') { if (typeof displayName === 'string') {
user.displayName = displayName.trim().slice(0, 80) user.displayName = displayName.trim().slice(0, 80)
} }
if (typeof adminShortcut === 'boolean') {
user.adminShortcut = adminShortcut
}
if (typeof newPassword === 'string') { if (typeof newPassword === 'string') {
if (newPassword.length < 8 || newPassword.length > 200) { if (newPassword.length < 8 || newPassword.length > 200) {
res.status(400).json({ message: 'Password must be 8–200 characters.' }); return res.status(400).json({ message: 'Password must be 8–200 characters.' }); return
} }
user.passwordHash = hashStudyPassword(newPassword) user.passwordHash = hashStudyPassword(newPassword)
for (const [token, session] of state.studySessions) { invalidateAllStudySessions(user.id)
if (session.userId === user.id) state.studySessions.delete(token) sendStudyPasswordChangedEmail(user.username, user.displayName || user.username)
} .catch(err => console.error('[admin] password-changed email error:', err))
} }
if (typeof addEnrollment === 'string' && addEnrollment.trim()) { if (typeof addEnrollment === 'string' && addEnrollment.trim()) {
@@ -174,16 +319,14 @@ export function register(app) {
user.updatedAt = new Date().toISOString() user.updatedAt = new Date().toISOString()
queueStudyUsersWrite() queueStudyUsersWrite()
res.json({ ok: true, displayName: user.displayName, enrolledStudySlugs: user.enrolledStudySlugs }) res.json({ ok: true, displayName: user.displayName, enrolledStudySlugs: user.enrolledStudySlugs, adminShortcut: user.adminShortcut === true })
}) })
app.delete('/api/admin/study-users/:id', requireAdminAuth, async (req, res) => { app.delete('/api/admin/study-users/:id', requireAdminAuth, async (req, res) => {
const user = state.studyUsers.find(u => u.id === req.params.id) const user = state.studyUsers.find(u => u.id === req.params.id)
if (!user) { res.status(404).json({ message: 'User not found.' }); return } if (!user) { res.status(404).json({ message: 'User not found.' }); return }
for (const [token, session] of state.studySessions) { invalidateAllStudySessions(user.id)
if (session.userId === user.id) state.studySessions.delete(token)
}
state.studyUsers = state.studyUsers.filter(u => u.id !== user.id) state.studyUsers = state.studyUsers.filter(u => u.id !== user.id)
queueStudyUsersWrite() queueStudyUsersWrite()
+1 -2
View File
@@ -3,6 +3,7 @@ import qrcode from 'qrcode'
import { parseCookies } from '../helpers.js' import { parseCookies } from '../helpers.js'
import { APP_VERSION, GIT_COMMIT } from '../config.js' import { APP_VERSION, GIT_COMMIT } from '../config.js'
import { import {
ADMIN_SESSION_COOKIE,
isAdminPasswordConfigured, isAdminPasswordConfigured,
isValidAdminSession, isValidAdminSession,
requireAdminAuth, requireAdminAuth,
@@ -24,8 +25,6 @@ import {
consumePendingSession, consumePendingSession,
} from '../auth.js' } from '../auth.js'
const ADMIN_SESSION_COOKIE = 'vbn_admin_session'
const loginRateLimiter = rateLimit({ const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 10, max: 10,
-3
View File
@@ -24,7 +24,6 @@ import {
loadDownloadCountsFromDisk, loadDownloadCountsFromDisk,
loadQrCodesFromDisk, loadQrCodesFromDisk,
loadEpisodePlaysFromDisk, loadEpisodePlaysFromDisk,
loadPodcastChecklistFromDisk,
loadAnalyticsEventsFromDisk, loadAnalyticsEventsFromDisk,
createBackupSnapshot, createBackupSnapshot,
refreshContentCaches, refreshContentCaches,
@@ -105,7 +104,6 @@ async function flushPendingWrites() {
state.draftQuestionsWritePromise, state.draftQuestionsWritePromise,
state.replyTemplatesWritePromise, state.replyTemplatesWritePromise,
state.replyHistoryWritePromise, state.replyHistoryWritePromise,
state.podcastChecklistWritePromise,
state.studyUsersWritePromise, state.studyUsersWritePromise,
state.studyCommunityWritePromise, state.studyCommunityWritePromise,
state.studyRemindersWritePromise, state.studyRemindersWritePromise,
@@ -148,7 +146,6 @@ async function reloadStateFromDisk() {
loadDownloadCountsFromDisk(), loadDownloadCountsFromDisk(),
loadQrCodesFromDisk(), loadQrCodesFromDisk(),
loadEpisodePlaysFromDisk(), loadEpisodePlaysFromDisk(),
loadPodcastChecklistFromDisk(),
loadAnalyticsEventsFromDisk(), loadAnalyticsEventsFromDisk(),
refreshContentCaches(), refreshContentCaches(),
]) ])
+200 -23
View File
@@ -1,4 +1,7 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises' import { applyFeedRelease } from '../feed-release.js'
import { mkdir, readFile } from 'node:fs/promises'
import { writeFile } from '../atomic-write.js'
import { assertPublicHttpsUrl } from '../url-safety.js'
import { sanitizeSiteContent } from '../helpers.js' import { sanitizeSiteContent } from '../helpers.js'
import { requireAdminAuth, isValidAdminSession } from '../auth.js' import { requireAdminAuth, isValidAdminSession } from '../auth.js'
import { import {
@@ -24,13 +27,11 @@ import {
queueContactSubmissionsWrite, queueContactSubmissionsWrite,
queueReplyTemplatesWrite, queueReplyTemplatesWrite,
queueReplyHistoryWrite, queueReplyHistoryWrite,
queuePodcastChecklistWrite,
queueWebhooksWrite, queueWebhooksWrite,
createBackupSnapshot, createBackupSnapshot,
listBackupPreviews, listBackupPreviews,
readBackupPreview, readBackupPreview,
restoreFromBackup, restoreFromBackup,
sanitizePodcastChecklist,
} from '../data.js' } from '../data.js'
import { import {
pruneStatsByDays, pruneStatsByDays,
@@ -53,6 +54,191 @@ function invokeWebhook(url, action) {
.catch(err => ({ ok: false, message: err instanceof Error ? err.message : `${action} webhook failed.` })) .catch(err => ({ ok: false, message: err instanceof Error ? err.message : `${action} webhook failed.` }))
} }
// The site can run two series at once ("lanes"): the primary series (flat series* fields)
// and an optional second series (secondSeries). Each lane has its own scheduled transition.
const SERIES_LANES = [
{ lane: 'primary', scheduleKey: 'scheduledSeriesTransition' },
{ lane: 'second', scheduleKey: 'scheduledSecondSeriesTransition' },
]
function readCurrentSeries(content, lane) {
if (lane === 'second') {
const s = content.secondSeries ?? {}
return {
title: s.title ?? '', description: s.description ?? '', imageUrl: s.imageUrl ?? '',
season: s.season, studySlug: s.studySlug ?? '',
guide: s.guide?.title ? s.guide : undefined,
}
}
return {
title: content.seriesTitle ?? '', description: content.seriesDescription ?? '', imageUrl: content.seriesImageUrl ?? '',
season: content.seriesSeason, studySlug: content.seriesStudySlug ?? '',
// The main series' guide lives in the flat studyGuide* fields
guide: content.studyGuideTitle?.trim()
? {
title: content.studyGuideTitle,
description: content.studyGuideDescription ?? '',
downloadUrl: content.studyGuideDownloadUrl ?? '',
amazonUrl: content.studyGuideUrl ?? '',
amazonLabel: content.studyGuideAmazonButtonLabel ?? '',
appleBooksUrl: content.studyGuideAppleBooksUrl ?? '',
appleBooksLabel: content.studyGuideAppleBooksLabel ?? '',
}
: undefined,
}
}
// Builds the content that results from applying one lane's scheduled series transition.
// Only the named lane changes; the other lane's series, studies and highlights are left alone.
function applySeriesTransition(content, sched, lane = 'primary') {
const scheduleKey = lane === 'second' ? 'scheduledSecondSeriesTransition' : 'scheduledSeriesTransition'
const current = readCurrentSeries(content, lane)
const newSeason = Number.isInteger(sched.newSeason) ? sched.newSeason : undefined
const newStudySlug = typeof sched.newStudySlug === 'string' ? sched.newStudySlug.trim().toLowerCase() : ''
// Highlights for the ending series move onto its Finished Book; the other lane's and general ones stay.
const allHighlights = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
const isThisLane = link => (link.lane === 'second' ? 'second' : link.lane === 'general' ? 'general' : 'primary') === lane
const endingHighlights = current.title.trim() ? allHighlights.filter(isThisLane) : []
const keptHighlights = current.title.trim() ? allHighlights.filter(link => !isThisLane(link)) : allHighlights
const archivedBook = current.title.trim()
? {
id: Date.now().toString(36),
title: current.title,
description: current.description,
imageUrl: current.imageUrl,
season: current.season ?? ((newSeason ?? 2) - 1 || 1),
...(current.studySlug ? { studySlug: current.studySlug } : {}),
...(current.guide ? { guide: current.guide } : {}),
...(endingHighlights.length > 0 ? { highlights: endingHighlights.map(({ lane: _lane, ...link }) => link) } : {}),
}
: null
const patch = {}
const copy = (from, to) => {
if (typeof sched[from] === 'string' && sched[from].trim()) patch[to] = sched[from].trim()
}
copy('newStartHereHeading', 'startHereHeading')
copy('newStartHereIntro', 'startHereIntro')
for (const n of [1, 2, 3]) {
copy(`newStartHereStep${n}Title`, `startHereStep${n}Title`)
copy(`newStartHereStep${n}Body`, `startHereStep${n}Body`)
copy(`newStartHereStep${n}Cta`, `startHereStep${n}Cta`)
}
copy('newStudyGuideTitle', 'studyGuideTitle')
copy('newStudyGuideDescription', 'studyGuideDescription')
copy('newStudyGuideDownloadUrl', 'studyGuideDownloadUrl')
copy('newStudyGuideUrl', 'studyGuideUrl')
copy('newStudyGuideAmazonButtonLabel', 'studyGuideAmazonButtonLabel')
copy('newStudyGuideAppleBooksUrl', 'studyGuideAppleBooksUrl')
copy('newStudyGuideAppleBooksLabel', 'studyGuideAppleBooksLabel')
// The finished series' study becomes Complete (still enrollable, self-paced); the
// incoming series' study opens up, taking the lane it launches in.
const laneLetter = lane === 'second' ? 'B' : 'A'
const studies = (Array.isArray(content.studies) ? content.studies : []).map(study => {
if (!study) return study
if (current.studySlug && study.slug === current.studySlug && study.status !== 'planned') {
return { ...study, status: 'complete' }
}
if (newStudySlug && study.slug === newStudySlug) {
return { ...study, status: 'active', lane: study.lane || laneLetter, startsAt: '' }
}
return study
})
const lanePatch = lane === 'second'
? {
secondSeries: {
...(content.secondSeries ?? {}),
label: 'Now Playing',
title: sched.newTitle.trim(),
description: sched.newDescription?.trim() ?? '',
imageUrl: sched.newImageUrl?.trim() || content.secondSeries?.imageUrl || '',
studySlug: newStudySlug,
// The ending series' guide moved onto its Finished Book; the incoming series brings its own
guide: sched.newGuide?.title?.trim() ? sched.newGuide : undefined,
...(newSeason ? { season: newSeason } : {}),
},
}
: {
seriesTitle: sched.newTitle.trim(),
seriesDescription: sched.newDescription?.trim() ?? '',
seriesImageUrl: sched.newImageUrl?.trim() || content.seriesImageUrl || '',
seriesLabel: 'Now Playing',
seriesStudySlug: newStudySlug,
...(newSeason ? { seriesSeason: newSeason } : {}),
// The ending series' guide moved onto its Finished Book; unless the launch brings a new one, clear it
...(current.guide && !sched.newStudyGuideTitle?.trim()
? {
studyGuideTitle: '', studyGuideDescription: '', studyGuideDownloadUrl: '', studyGuideUrl: '',
studyGuideAmazonButtonLabel: '', studyGuideAppleBooksUrl: '', studyGuideAppleBooksLabel: '',
}
: {}),
}
const next = sanitizeSiteContent({
...content,
...lanePatch,
studies,
podcastFeaturedLinks: keptHighlights,
finishedBooks: [...(Array.isArray(content.finishedBooks) ? content.finishedBooks : []), ...(archivedBook ? [archivedBook] : [])],
...patch,
})
delete next[scheduleKey]
return { next, archivedBook }
}
// Applies any scheduled series transitions (one per lane) whose triggersAt time has passed.
// A schedule is saved with the draft (Save Schedule), so look in the draft as well as
// published content. Applies to both so a later publish doesn't undo it.
// Called on a server interval (see server.js). Safe to call if no transition is set.
export async function runScheduledSeriesTransition() {
const published = state.cachedSiteContent
const draft = state.cachedDraftSiteContent
if (!published) return
let nextPublished = published
let nextDraft = draft
const fired = []
for (const { lane, scheduleKey } of SERIES_LANES) {
const sched = nextDraft?.[scheduleKey] ?? nextPublished[scheduleKey]
if (!sched || !sched.triggersAt || !sched.newTitle?.trim()) continue
const triggerTime = new Date(sched.triggersAt).getTime()
if (Number.isNaN(triggerTime) || triggerTime > Date.now()) continue
console.log(`[series-scheduler] Firing scheduled ${lane} series transition to:`, sched.newTitle)
const result = applySeriesTransition(nextPublished, sched, lane)
nextPublished = result.next
if (nextDraft) nextDraft = applySeriesTransition(nextDraft, sched, lane).next
fired.push(result.archivedBook?.title ?? '(nothing archived)')
}
if (fired.length === 0) return
const publishedAt = new Date().toISOString()
try {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(DATA_FILE, JSON.stringify({ siteContent: nextPublished, updatedAt: publishedAt }, null, 2), 'utf8')
state.cachedSiteContent = nextPublished
state.publishState.publishedAt = publishedAt
console.log('[series-scheduler] Transition complete — archived:', fired.join(', '))
} catch (err) {
console.error('[series-scheduler] Failed to write transition:', err)
return
}
if (nextDraft && nextDraft !== draft) {
try {
await writeFile(DRAFT_DATA_FILE, JSON.stringify({ siteContent: nextDraft, updatedAt: publishedAt }, null, 2), 'utf8')
state.cachedDraftSiteContent = nextDraft
state.publishState.draftUpdatedAt = publishedAt
} catch (err) {
console.error('[series-scheduler] Failed to update draft after transition:', err)
}
}
}
export function register(app) { export function register(app) {
app.get('/api/admin-content', async (req, res) => { app.get('/api/admin-content', async (req, res) => {
const source = req.query?.source === 'draft' ? 'draft' : 'published' const source = req.query?.source === 'draft' ? 'draft' : 'published'
@@ -64,7 +250,7 @@ export function register(app) {
try { try {
const parsed = await loadSiteContentFile(source === 'draft' ? DRAFT_DATA_FILE : DATA_FILE) const parsed = await loadSiteContentFile(source === 'draft' ? DRAFT_DATA_FILE : DATA_FILE)
if (source === 'published') { if (source === 'published') {
const safeSiteContent = filterSiteContentByReleaseDate(parsed.siteContent) const safeSiteContent = filterSiteContentByReleaseDate(await applyFeedRelease(parsed.siteContent))
res.json({ ...parsed, siteContent: safeSiteContent }) res.json({ ...parsed, siteContent: safeSiteContent })
return return
} }
@@ -91,25 +277,10 @@ export function register(app) {
res.json(status) res.json(status)
}) })
app.get('/api/admin-podcast-checklist', requireAdminAuth, (_req, res) => {
res.json({ checklist: state.podcastChecklist })
})
app.put('/api/admin-podcast-checklist', requireAdminAuth, async (req, res) => {
try {
const safeChecklist = sanitizePodcastChecklist(req.body?.checklist)
state.podcastChecklist = safeChecklist
await queuePodcastChecklistWrite()
res.json({ ok: true, checklist: safeChecklist })
} catch {
res.status(500).json({ message: 'Failed to save podcast checklist.' })
}
})
app.get('/api/site-config', async (_req, res) => { app.get('/api/site-config', async (_req, res) => {
try { try {
const parsed = await loadSiteContentFile(DATA_FILE) const parsed = await loadSiteContentFile(DATA_FILE)
const siteContent = parsed.siteContent ?? {} const siteContent = await applyFeedRelease(parsed.siteContent ?? {})
res.json({ res.json({
seo: siteContent.seo ?? DEFAULT_SEO, seo: siteContent.seo ?? DEFAULT_SEO,
legal: siteContent.legal ?? DEFAULT_LEGAL, legal: siteContent.legal ?? DEFAULT_LEGAL,
@@ -227,9 +398,9 @@ export function register(app) {
app.get('/api/admin-ops/status', requireAdminAuth, (_req, res) => { app.get('/api/admin-ops/status', requireAdminAuth, (_req, res) => {
res.json({ res.json({
buildCommit: process.env.BUILD_COMMIT ?? null, buildCommit: process.env.BUILD_COMMIT ?? process.env.COMMIT_SHA ?? null,
buildNumber: process.env.BUILD_NUMBER ?? null, buildNumber: process.env.BUILD_NUMBER ?? null,
deployedAt: process.env.DEPLOYED_AT ?? null, deployedAt: process.env.DEPLOYED_AT ?? state.serverStartedAt,
cachePurge: state.lastCachePurgeStatus, cachePurge: state.lastCachePurgeStatus,
deployHook: state.lastDeployHookStatus, deployHook: state.lastDeployHookStatus,
}) })
@@ -339,12 +510,18 @@ export function register(app) {
res.json({ webhooks: state.webhooks }) res.json({ webhooks: state.webhooks })
}) })
app.post('/api/admin-webhooks', requireAdminAuth, (req, res) => { app.post('/api/admin-webhooks', requireAdminAuth, async (req, res) => {
const { url, events } = req.body ?? {} const { url, events } = req.body ?? {}
if (!url || typeof url !== 'string' || !/^https:\/\//i.test(url.trim())) { if (!url || typeof url !== 'string' || !/^https:\/\//i.test(url.trim())) {
res.status(400).json({ message: 'A valid HTTPS URL is required.' }) res.status(400).json({ message: 'A valid HTTPS URL is required.' })
return return
} }
try {
await assertPublicHttpsUrl(url.trim())
} catch (err) {
res.status(400).json({ message: `Webhook URL rejected: ${err.message}` })
return
}
if (state.webhooks.length >= 50) { if (state.webhooks.length >= 50) {
res.status(400).json({ message: 'Maximum of 50 webhooks reached.' }) res.status(400).json({ message: 'Maximum of 50 webhooks reached.' })
return return
+255
View File
@@ -0,0 +1,255 @@
import { Resend } from 'resend'
import { readdir } from 'node:fs/promises'
import { requireAdminAuth } from '../auth.js'
import { buildSeasonCheck } from '../season-check.js'
import { countPendingUploads } from './nuggets-workflow.js'
import { state } from '../state.js'
import { getStorageStatus, loadUserProgress } from '../data.js'
import { getStudyCatalog } from '../study-helpers.js'
import { resendRetry } from '../email.js'
import { fetchAllEpisodes } from './episodes.js'
import { loadHistory } from './nuggets-host-upload.js'
import { BACKUP_DIR } from '../config.js'
const DAY_MS = 24 * 60 * 60 * 1000
const STALE_FEED_DAYS = 21
function dayKey(offsetDays) {
return new Date(Date.now() - offsetDays * DAY_MS).toISOString().slice(0, 10)
}
function countSince(items, getDate, fromMs, toMs = Infinity) {
let n = 0
for (const item of items) {
const t = new Date(getDate(item) ?? 0).getTime()
if (t >= fromMs && t < toMs) n++
}
return n
}
function dailySeries(items, getDate, days) {
const buckets = new Map()
for (let i = days - 1; i >= 0; i--) buckets.set(dayKey(i), 0)
for (const item of items) {
const key = String(getDate(item) ?? '').slice(0, 10)
if (buckets.has(key)) buckets.set(key, buckets.get(key) + 1)
}
return [...buckets.values()]
}
function topCounts(values, limit) {
const counts = {}
for (const v of values) if (v) counts[v] = (counts[v] ?? 0) + 1
return Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, limit)
}
function buildTrends() {
const now = Date.now()
const week = 7 * DAY_MS
const subscribers = state.contactSubmissions.filter(s => s.subscribe === true)
const studyUsers = state.studyUsers
const realByDay = state.hitStats.byDayReal ?? {}
const visitSeries = Array.from({ length: 14 }, (_, i) => realByDay[dayKey(13 - i)] ?? 0)
return {
visits: {
thisWeek: visitSeries.slice(7).reduce((a, b) => a + b, 0),
lastWeek: visitSeries.slice(0, 7).reduce((a, b) => a + b, 0),
series: visitSeries,
},
subscribers: {
thisWeek: countSince(subscribers, s => s.submittedAt, now - week),
lastWeek: countSince(subscribers, s => s.submittedAt, now - 2 * week, now - week),
series: dailySeries(subscribers, s => s.submittedAt, 14),
},
studySignups: {
thisWeek: countSince(studyUsers, u => u.createdAt, now - week),
lastWeek: countSince(studyUsers, u => u.createdAt, now - 2 * week, now - week),
series: dailySeries(studyUsers, u => u.createdAt, 14),
},
}
}
function buildTopTraffic() {
const cutoff = dayKey(6)
const rows = (state.visitorStats.recentVisits ?? []).filter(r => !r.isBot && r.at && r.at >= cutoff)
return {
topPages: topCounts(rows.map(r => r.path ?? '/'), 5).map(([path, hits]) => ({ path, hits })),
topReferrers: topCounts(rows.map(r => r.referrer), 5).map(([referrer, count]) => ({ referrer, count })),
}
}
function buildReengagement() {
const now = Date.now()
const tiers = { t1: 0, t2: 0, t3: 0 }
let recentlySent = 0
for (const user of state.studyUsers) {
const sent = user.reengagementSentAt && typeof user.reengagementSentAt === 'object' ? user.reengagementSentAt : {}
for (const at of Object.values(sent)) {
if (now - new Date(at).getTime() <= 30 * DAY_MS) recentlySent++
}
if (!user.studyRemindersEnabled || !user.lastLoginAt || !(user.enrolledStudySlugs?.length)) continue
const days = (now - new Date(user.lastLoginAt).getTime()) / DAY_MS
if (days >= 30) tiers.t3++
else if (days >= 14) tiers.t2++
else if (days >= 7) tiers.t1++
}
return { ...tiers, recentlySent, remindersEnabled: state.studyUsers.filter(u => u.studyRemindersEnabled).length }
}
async function buildStudyProgress() {
const catalog = getStudyCatalog()
const perStudy = new Map(catalog.map(s => [s.slug, { slug: s.slug, title: s.title, enrolled: 0, lessonsCompleted: 0, certificates: 0 }]))
let lastActivityAt = null
await Promise.all(state.studyUsers.map(async user => {
for (const slug of user.enrolledStudySlugs ?? []) {
const row = perStudy.get(slug)
if (row) row.enrolled++
}
try {
const progress = await loadUserProgress(user.id)
for (const [slug, data] of Object.entries(progress.byStudy ?? {})) {
const row = perStudy.get(slug)
if (row) row.lessonsCompleted += (data.completedSectionIds ?? []).length
}
if (progress.updatedAt && (!lastActivityAt || progress.updatedAt > lastActivityAt)) lastActivityAt = progress.updatedAt
} catch { /* ignore unreadable progress */ }
}))
for (const cert of state.studyCertificates ?? []) {
const row = perStudy.get(cert.studySlug)
if (row) row.certificates++
}
return {
studies: [...perStudy.values()].filter(s => s.enrolled > 0 || s.lessonsCompleted > 0 || s.certificates > 0),
certificatesTotal: (state.studyCertificates ?? []).length,
lastActivityAt,
}
}
async function buildLatestEpisode() {
try {
const episodes = await fetchAllEpisodes()
const latest = episodes[0]
if (!latest) return { available: false }
const published = new Date(latest.pubDate)
const valid = !isNaN(published.getTime())
const daysAgo = valid ? Math.floor((Date.now() - published.getTime()) / DAY_MS) : null
return {
available: true,
title: latest.title,
episode: latest.episode,
season: latest.season,
publishedAt: valid ? published.toISOString() : null,
daysAgo,
stale: daysAgo !== null && daysAgo > STALE_FEED_DAYS,
staleAfterDays: STALE_FEED_DAYS,
}
} catch {
return { available: false }
}
}
async function buildNuggets() {
const { entries } = await loadHistory()
const weekAgo = Date.now() - 7 * DAY_MS
return {
uploadsThisWeek: entries.filter(e => new Date(e.uploadedAt).getTime() >= weekAgo).length,
pending: await countPendingUploads(),
latest: entries.slice(0, 3).map(e => ({ hostName: e.hostName, episodeName: e.episodeName, uploadedAt: e.uploadedAt })),
entries: entries.slice(0, 10),
}
}
async function buildHealth() {
let backupCount = null
try { backupCount = (await readdir(BACKUP_DIR)).filter(f => f.endsWith('.json')).length } catch { /* no backup dir yet */ }
const storage = await getStorageStatus().catch(() => null)
return {
resendConfigured: Boolean(process.env.RESEND_API_KEY),
lastBackup: state.lastBackupStatus,
backupCount,
dataDirWritable: storage ? storage.writable?.ok === true : null,
writes: { hitStats: state.lastHitStatsWrite, visitorStats: state.lastVisitorStatsWrite },
serverStartedAt: state.serverStartedAt,
}
}
// The Resend SDK (6.x) exposes no open/click metrics for broadcasts, so this reports
// the most recent send and links to the Resend dashboard for engagement numbers.
async function buildLastBroadcast() {
if (!process.env.RESEND_API_KEY) return { available: false, reason: 'RESEND_API_KEY is not configured.' }
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const { data: list, error } = await resendRetry(() => resend.broadcasts.list())
if (error) throw new Error(error.message)
const sent = (list?.data ?? []).filter(b => b.status === 'sent')
.sort((a, b) => new Date(b.sent_at ?? 0).getTime() - new Date(a.sent_at ?? 0).getTime())[0]
if (!sent) return { available: true, broadcast: null }
const { data, error: getError } = await resendRetry(() => resend.broadcasts.get(sent.id))
if (getError || !data) throw new Error(getError?.message ?? 'Broadcast not found.')
return {
available: true,
broadcast: {
id: data.id,
name: data.name,
subject: data.subject,
sentAt: data.sent_at ?? sent.sent_at ?? null,
dashboardUrl: `https://resend.com/broadcasts/${data.id}`,
},
}
} catch (err) {
return { available: false, reason: err instanceof Error ? err.message : 'Could not reach Resend.' }
}
}
function buildActivity(nuggetEntries) {
const items = []
for (const c of state.contactSubmissions) items.push({ at: c.submittedAt, kind: 'contact', text: `${c.name || c.email} sent a contact message` })
for (const q of state.questions) items.push({ at: q.submittedAt, kind: 'question', text: `${q.firstName || 'Someone'} asked a question` })
for (const c of state.studyComments) items.push({ at: c.createdAt, kind: 'comment', text: `${c.displayName || 'A student'} commented on a study` })
for (const u of state.studyUsers) items.push({ at: u.createdAt, kind: 'study-user', text: `${u.displayName || u.username} created a study account` })
for (const c of state.studyCertificates ?? []) items.push({ at: c.issuedAt, kind: 'certificate', text: `${c.displayName} earned a certificate: ${c.studyTitle}` })
for (const e of nuggetEntries) items.push({ at: e.uploadedAt, kind: 'nuggets', text: `${e.hostName || 'A host'} uploaded "${e.episodeName}"` })
return items
.filter(i => i.at && !isNaN(new Date(i.at).getTime()))
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
.slice(0, 12)
}
export function register(app) {
app.get('/api/admin-dashboard', requireAdminAuth, async (_req, res) => {
try {
const [studyProgress, latestEpisode, nuggets, health, lastBroadcast] = await Promise.all([
buildStudyProgress(), buildLatestEpisode(), buildNuggets(), buildHealth(), buildLastBroadcast(),
])
const comments = state.studyComments ?? []
const seasonCheck = await buildSeasonCheck()
.then(c => ({
total: c.total,
errors: c.errors,
warnings: c.warnings,
general: c.general.length,
lanes: c.lanes.map(l => ({ lane: l.lane, title: l.title, season: l.season, errors: l.errors, warnings: l.warnings })),
}))
.catch(() => null)
res.json({
seasonCheck,
trends: buildTrends(),
traffic: buildTopTraffic(),
reengagement: buildReengagement(),
studyProgress,
latestEpisode,
nuggets: { uploadsThisWeek: nuggets.uploadsThisWeek, pending: nuggets.pending, latest: nuggets.latest },
health,
lastBroadcast,
comments: {
pending: comments.filter(c => c.isApproved !== true).length,
thisWeek: countSince(comments, c => c.createdAt, Date.now() - 7 * DAY_MS),
},
activity: buildActivity(nuggets.entries),
})
} catch (err) {
console.error('[admin-dashboard] failed:', err)
res.status(500).json({ message: 'Failed to build dashboard data.' })
}
})
}
+138 -48
View File
@@ -1,4 +1,5 @@
import { createHash, randomUUID } from 'node:crypto' import { createHash, randomUUID } from 'node:crypto'
import rateLimit from 'express-rate-limit'
import { requireAdminAuth, isValidAdminSession } from '../auth.js' import { requireAdminAuth, isValidAdminSession } from '../auth.js'
import { getClientIp, hasVisitorConsent, setConsentCookie, parseCookies } from '../helpers.js' import { getClientIp, hasVisitorConsent, setConsentCookie, parseCookies } from '../helpers.js'
import { getStudyUserFromRequest } from '../study-helpers.js' import { getStudyUserFromRequest } from '../study-helpers.js'
@@ -19,15 +20,16 @@ import {
buildLastNDaysStats, buildLastNDaysStats,
shouldCountHit, shouldCountHit,
recordHit, recordHit,
isKnownSitePath,
getStudyCatalog,
} from '../study-helpers.js' } from '../study-helpers.js'
import { getStudyCatalog } from '../study-helpers.js'
async function resolveGeo(ip) { async function resolveGeo(ip, ipHash) {
if (!ip || isPrivateOrLocalIp(ip)) { if (!ip || isPrivateOrLocalIp(ip)) {
return { country: 'Local/Unknown', state: 'Local/Unknown', county: 'Local/Unknown', city: 'Local/Unknown' } return { country: 'Local/Unknown', state: 'Local/Unknown', county: 'Local/Unknown', city: 'Local/Unknown' }
} }
const cached = state.visitorStats.geoCacheByIp[ip] const cached = state.visitorStats.geoCacheByIp[ipHash]
if (cached) return cached if (cached) return cached
const providers = [ const providers = [
@@ -62,7 +64,7 @@ async function resolveGeo(ip) {
try { try {
const geo = await provider() const geo = await provider()
if (geo) { if (geo) {
state.visitorStats.geoCacheByIp[ip] = geo state.visitorStats.geoCacheByIp[ipHash] = geo
queueVisitorStatsWrite() queueVisitorStatsWrite()
return geo return geo
} }
@@ -70,17 +72,44 @@ async function resolveGeo(ip) {
} }
const fallback = { country: 'Unknown', state: 'Unknown', county: 'Unknown', city: 'Unknown' } const fallback = { country: 'Unknown', state: 'Unknown', county: 'Unknown', city: 'Unknown' }
state.visitorStats.geoCacheByIp[ip] = fallback state.visitorStats.geoCacheByIp[ipHash] = fallback
queueVisitorStatsWrite() queueVisitorStatsWrite()
return fallback return fallback
} }
export async function recordVisitor(req, res, overridePath = null, overrideReferrer = null) { // Simple in-memory rate limiter for pageview endpoint.
// This keys on req.ip, which collapses to one value for every visitor behind
// a shared IP (a proxy in front of the app, a school/church network, CGNAT).
// If TRUST_PROXY_HOPS is misconfigured for the real deployment topology,
// req.ip silently becomes the proxy's IP for everyone and this limiter starts
// dropping real traffic across all of them together. Log the first drop per
// IP per window so that scenario shows up instead of vanishing silently.
const pageviewHits = new Map()
function pageviewAllowed(ip) {
const now = Date.now()
const windowMs = 60 * 1000
const entry = pageviewHits.get(ip) ?? { count: 0, start: now, warned: false }
if (now - entry.start > windowMs) { entry.count = 0; entry.start = now; entry.warned = false }
entry.count += 1
pageviewHits.set(ip, entry)
if (pageviewHits.size > 5000) {
const cutoff = now - windowMs
for (const [k, v] of pageviewHits) { if (v.start < cutoff) pageviewHits.delete(k) }
}
const allowed = entry.count <= 60
if (!allowed && !entry.warned) {
entry.warned = true
console.warn(`[analytics] pageview rate limit hit for an IP (${entry.count} requests/min) — if this fires often, check TRUST_PROXY_HOPS against the real deployment topology, since a misconfigured hop count collapses many visitors onto one IP here`)
}
return allowed
}
export function recordVisitor(req, res, overridePath = null, overrideReferrer = null) {
const cookies = parseCookies(req.headers.cookie) const cookies = parseCookies(req.headers.cookie)
let visitorId = cookies[VISITOR_COOKIE] let visitorId = cookies[VISITOR_COOKIE]
const hadCookie = Boolean(visitorId)
if (!visitorId) { if (!visitorId) {
visitorId = randomUUID() visitorId = randomUUID()
res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax`)
} }
const nowIso = new Date().toISOString() const nowIso = new Date().toISOString()
@@ -95,12 +124,20 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
: null : null
const ipHash = createHash('sha256').update(ip).digest('hex') const ipHash = createHash('sha256').update(ip).digest('hex')
const geo = await resolveGeo(ip)
const existingIdByIp = state.visitorStats.ipHashIndex[ipHash] // Only fall back to IP-based correlation when the browser sent no cookie
if (existingIdByIp && existingIdByIp !== visitorId) { // at all — e.g. it never persisted (Safari ITP, a cookie-blocking
visitorId = existingIdByIp // extension) — so that one real person doesn't get double-counted every
res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax`) // visit. If a cookie WAS sent, trust it: two different people sharing a
// network (family wifi, a school or church LAN) must not be silently
// merged into one visitor just because they share an IP, which would
// corrupt page history and undercount unique visitors.
if (!hadCookie) {
const existingIdByIp = state.visitorStats.ipHashIndex[ipHash]
if (existingIdByIp) {
visitorId = existingIdByIp
}
res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax; HttpOnly`)
} }
const existingVisitor = state.visitorStats.visitors[visitorId] const existingVisitor = state.visitorStats.visitors[visitorId]
@@ -123,8 +160,12 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
knownIdentities.push(studyIdentity) knownIdentities.push(studyIdentity)
} }
// Use cached geo immediately; will be resolved async if not yet cached
const cachedGeo = state.visitorStats.geoCacheByIp[ipHash]
const geo = cachedGeo ?? { country: 'Unknown', state: 'Unknown', county: 'Unknown', city: 'Unknown' }
state.visitorStats.visitors[visitorId] = { state.visitorStats.visitors[visitorId] = {
visitorId, ip, ipHash, visitorId, ipHash,
firstSeenAt: existingVisitor?.firstSeenAt ?? nowIso, firstSeenAt: existingVisitor?.firstSeenAt ?? nowIso,
lastSeenAt: nowIso, lastSeenAt: nowIso,
visitCount: nextVisitCount, visitCount: nextVisitCount,
@@ -141,7 +182,8 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
state.visitorStats.firstVisitAt = state.visitorStats.firstVisitAt ?? nowIso state.visitorStats.firstVisitAt = state.visitorStats.firstVisitAt ?? nowIso
state.visitorStats.lastVisitAt = nowIso state.visitorStats.lastVisitAt = nowIso
state.visitorStats.recentVisits.unshift({ state.visitorStats.recentVisits.unshift({
at: nowIso, visitorId, ip, path: pathKey, referrer, device, at: nowIso, visitorId, ipHash,
path: pathKey, referrer, device,
country: geo.country, state: geo.state, county: geo.county, city: geo.city, country: geo.country, state: geo.state, county: geo.county, city: geo.city,
returningVisitor: isReturning, visitCount: nextVisitCount, returningVisitor: isReturning, visitCount: nextVisitCount,
...(studyIdentity ? { studyUser: studyIdentity } : {}), ...(studyIdentity ? { studyUser: studyIdentity } : {}),
@@ -149,9 +191,36 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
state.visitorStats.recentVisits = state.visitorStats.recentVisits.slice(0, MAX_RECENT_VISITS) state.visitorStats.recentVisits = state.visitorStats.recentVisits.slice(0, MAX_RECENT_VISITS)
queueVisitorStatsWrite() queueVisitorStatsWrite()
// Resolve geo in background if not already cached
if (!cachedGeo) {
resolveGeo(ip, ipHash).then(resolvedGeo => {
if (state.visitorStats.visitors[visitorId]) {
state.visitorStats.visitors[visitorId].location = resolvedGeo
}
const entry = state.visitorStats.recentVisits.find(v => v.visitorId === visitorId && v.at === nowIso)
if (entry) {
entry.country = resolvedGeo.country
entry.state = resolvedGeo.state
entry.county = resolvedGeo.county
entry.city = resolvedGeo.city
}
queueVisitorStatsWrite()
}).catch(() => {})
}
} }
const analyticsWriteLimiter = rateLimit({
windowMs: 60 * 1000,
max: 120,
standardHeaders: true,
legacyHeaders: false,
message: { ok: false, reason: 'rate-limited' },
})
export function register(app) { export function register(app) {
app.use(['/api/analytics/play', '/api/analytics/event'], analyticsWriteLimiter)
app.post('/api/analytics-consent', (req, res) => { app.post('/api/analytics-consent', (req, res) => {
const consent = req.body?.consent === true const consent = req.body?.consent === true
setConsentCookie(res, consent) setConsentCookie(res, consent)
@@ -160,6 +229,10 @@ export function register(app) {
app.post('/api/analytics/play', (req, res) => { app.post('/api/analytics/play', (req, res) => {
if (isValidAdminSession(req)) { res.json({ ok: false, reason: 'admin' }); return } if (isValidAdminSession(req)) { res.json({ ok: false, reason: 'admin' }); return }
if (!hasVisitorConsent(req)) { res.json({ ok: false, reason: 'no-consent' }); return }
const ua = req.get('user-agent') ?? ''
const { isBot } = detectBot(ua)
if (isBot) { res.json({ ok: false, reason: 'bot' }); return }
const title = typeof req.body?.title === 'string' ? req.body.title.trim().slice(0, 200) : '' const title = typeof req.body?.title === 'string' ? req.body.title.trim().slice(0, 200) : ''
if (!title) { res.status(400).json({ ok: false, reason: 'missing-title' }); return } if (!title) { res.status(400).json({ ok: false, reason: 'missing-title' }); return }
recordEpisodePlay(title) recordEpisodePlay(title)
@@ -179,52 +252,64 @@ export function register(app) {
res.json({ ok: true }) res.json({ ok: true })
}) })
app.post('/api/analytics/pageview', async (req, res) => { app.post('/api/analytics/pageview', (req, res) => {
if (isValidAdminSession(req)) { if (isValidAdminSession(req)) { res.json({ ok: false, reason: 'admin' }); return }
res.json({ ok: false, reason: 'admin' }); return if (!hasVisitorConsent(req)) { res.json({ ok: false, reason: 'no-consent' }); return }
}
if (!hasVisitorConsent(req)) {
res.json({ ok: false, reason: 'no-consent' }); return
}
const ua = req.get('user-agent') ?? '' const ua = req.get('user-agent') ?? ''
const { isBot } = detectBot(ua) const { isBot } = detectBot(ua)
if (isBot) { if (isBot) { res.json({ ok: false, reason: 'bot' }); return }
res.json({ ok: false, reason: 'bot' }); return const ip = getClientIp(req)
} if (!pageviewAllowed(ip)) { res.json({ ok: false, reason: 'rate-limited' }); return }
const rawPath = typeof req.body?.path === 'string' ? req.body.path : '/' const rawPath = typeof req.body?.path === 'string' ? req.body.path : '/'
if (!isKnownSitePath(rawPath)) { res.json({ ok: false, reason: 'unknown-path' }); return }
const rawReferrer = typeof req.body?.referrer === 'string' ? req.body.referrer : '' const rawReferrer = typeof req.body?.referrer === 'string' ? req.body.referrer : ''
recordHit(rawPath, false) recordHit(rawPath, false)
queueHitStatsWrite() queueHitStatsWrite()
await recordVisitor(req, res, rawPath, rawReferrer) recordVisitor(req, res, rawPath, rawReferrer)
res.json({ ok: true }) res.json({ ok: true })
}) })
app.get('/api/admin-stats', requireAdminAuth, (req, res) => { app.get('/api/admin-stats', requireAdminAuth, (req, res) => {
// Time-range filter: 7d | 30d | 90d (default: all-time for aggregate, 30d for charts)
const rangeParam = req.query.range const rangeParam = req.query.range
const rangeDays = rangeParam === '7d' ? 7 : rangeParam === '90d' ? 90 : 30 const isAllTime = rangeParam === 'all'
const rangeLabel = rangeParam === '7d' ? '7d' : rangeParam === '90d' ? '90d' : '30d' const rangeDays = rangeParam === '7d' ? 7 : rangeParam === '90d' ? 90 : isAllTime ? null : 30
const rangeLabel = rangeParam === '7d' ? 'Last 7 days' : rangeParam === '90d' ? 'Last 90 days' : isAllTime ? 'All time' : 'Last 30 days'
// Compute a cutoff date string (YYYY-MM-DD) for filtering daily buckets // Cutoff date (null = all-time)
const cutoffDate = (() => { const cutoffDate = isAllTime ? null : (() => {
const d = new Date() const d = new Date()
d.setDate(d.getDate() - (rangeDays - 1)) d.setDate(d.getDate() - ((rangeDays ?? 30) - 1))
return d.toISOString().slice(0, 10) return d.toISOString().slice(0, 10)
})() })()
// Filter byDayReal/byDayBot keys to only those within the range // All known day keys, sorted
const filteredDayKeys = Object.keys(state.hitStats.byDayReal ?? {}).filter(day => day >= cutoffDate) const allDayKeys = new Set([
...Object.keys(state.hitStats.byDayReal ?? {}),
...Object.keys(state.hitStats.byDayBot ?? {}),
])
const filteredDayKeys = isAllTime
? [...allDayKeys].sort()
: [...allDayKeys].filter(day => day >= cutoffDate).sort()
// Aggregate hits for the range // Aggregate hits for the range
const rangeRealHits = filteredDayKeys.reduce((sum, day) => sum + (state.hitStats.byDayReal?.[day] ?? 0), 0) const rangeRealHits = filteredDayKeys.reduce((sum, day) => sum + (state.hitStats.byDayReal?.[day] ?? 0), 0)
const rangeBotHits = filteredDayKeys.reduce((sum, day) => sum + (state.hitStats.byDayBot?.[day] ?? 0), 0) const rangeBotHits = filteredDayKeys.reduce((sum, day) => sum + (state.hitStats.byDayBot?.[day] ?? 0), 0)
const rangeTotalHits = rangeRealHits + rangeBotHits const rangeTotalHits = rangeRealHits + rangeBotHits
// Filter per-path stats by range — approximate using recent visitor rows scoped to range // Trend chart data — one point per day for the selected range
const rangeVisitorRows = state.visitorStats.recentVisits.filter(row => { const trendData = filteredDayKeys.map(day => ({
if (!row.visitedAt) return true // include if no timestamp day,
return row.visitedAt >= cutoffDate real: state.hitStats.byDayReal?.[day] ?? 0,
}) bot: state.hitStats.byDayBot?.[day] ?? 0,
}))
// Visitor rows for range
const rangeVisitorRows = isAllTime
? state.visitorStats.recentVisits
: state.visitorStats.recentVisits.filter(row => {
if (!row.at) return false
return row.at >= cutoffDate
})
const rangePathCountsReal = {} const rangePathCountsReal = {}
const rangePathCountsBot = {} const rangePathCountsBot = {}
@@ -253,9 +338,6 @@ export function register(app) {
const botReasons = Object.entries(state.hitStats.botReasons ?? {}) const botReasons = Object.entries(state.hitStats.botReasons ?? {})
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([reason, count]) => ({ reason, count })) .sort((a, b) => b[1] - a[1]).slice(0, 10).map(([reason, count]) => ({ reason, count }))
// All range rows used for geographic/device/referrer aggregates — no cap so no visits are invisible
const allRangeRows = rangeVisitorRows
// Capped to 100 for the visitor log display (with page history hydrated) // Capped to 100 for the visitor log display (with page history hydrated)
const recentVisitorRows = rangeVisitorRows.slice(0, 100).map(row => { const recentVisitorRows = rangeVisitorRows.slice(0, 100).map(row => {
const fullVisitor = state.visitorStats.visitors[row.visitorId] const fullVisitor = state.visitorStats.visitors[row.visitorId]
@@ -291,7 +373,6 @@ export function register(app) {
const enrolledUsers = state.studyUsers.filter(user => (user.enrolledStudySlugs?.length ?? 0) > 0).length const enrolledUsers = state.studyUsers.filter(user => (user.enrolledStudySlugs?.length ?? 0) > 0).length
const totalEnrollments = Object.values(enrollmentCountsBySlug).reduce((sum, count) => sum + count, 0) const totalEnrollments = Object.values(enrollmentCountsBySlug).reduce((sum, count) => sum + count, 0)
// Enrollment funnel: signups → first study visit → first section completed
const funnelSignups = state.studyUsers.length const funnelSignups = state.studyUsers.length
const funnelFirstVisit = state.studyUsers.filter(u => u.firstVisitAt).length const funnelFirstVisit = state.studyUsers.filter(u => u.firstVisitAt).length
const funnelFirstCompletion = state.studyUsers.filter(u => u.firstCompletionAt).length const funnelFirstCompletion = state.studyUsers.filter(u => u.firstCompletionAt).length
@@ -303,6 +384,8 @@ export function register(app) {
rangeTotalHits, rangeTotalHits,
rangeLabel, rangeLabel,
rangeDays, rangeDays,
isAllTime,
trendData,
// All-time totals for reference // All-time totals for reference
allTimeRealHits: state.hitStats.realHits ?? 0, allTimeRealHits: state.hitStats.realHits ?? 0,
allTimeBotHits: state.hitStats.botHits ?? 0, allTimeBotHits: state.hitStats.botHits ?? 0,
@@ -310,6 +393,13 @@ export function register(app) {
firstHitAt: state.hitStats.firstHitAt, firstHitAt: state.hitStats.firstHitAt,
lastHitAt: state.hitStats.lastHitAt, lastHitAt: state.hitStats.lastHitAt,
topPaths, topPathsReal, topPathsBot, topPaths, topPathsReal, topPathsBot,
topUnknownPaths: Object.entries(state.hitStats.byPathUnknown ?? {})
.sort((a, b) => b[1] - a[1]).slice(0, 25).map(([path, hits]) => ({ path, hits })),
uniqueVisitors7d: new Set(
state.visitorStats.recentVisits
.filter(v => Date.now() - new Date(v.at).getTime() <= 7 * 24 * 60 * 60 * 1000)
.map(v => v.visitorId),
).size,
last7Days, last7DaysReal, last7DaysBot, last7Days, last7DaysReal, last7DaysBot,
last30DaysTotal, last30DaysRealTotal, last30DaysBotTotal, last30DaysTotal, last30DaysRealTotal, last30DaysBotTotal,
botReasons, botReasons,
@@ -319,13 +409,13 @@ export function register(app) {
returningVisits: state.visitorStats.returningVisits, returningVisits: state.visitorStats.returningVisits,
firstVisitAt: state.visitorStats.firstVisitAt, firstVisitAt: state.visitorStats.firstVisitAt,
lastVisitAt: state.visitorStats.lastVisitAt, lastVisitAt: state.visitorStats.lastVisitAt,
topCountries: buildTopLocations(allRangeRows, 'country'), topCountries: buildTopLocations(rangeVisitorRows, 'country'),
topStates: buildTopLocations(allRangeRows, 'state'), topStates: buildTopLocations(rangeVisitorRows, 'state'),
topCounties: buildTopLocations(allRangeRows, 'county'), topCounties: buildTopLocations(rangeVisitorRows, 'county'),
topCities: buildTopLocations(allRangeRows, 'city'), topCities: buildTopLocations(rangeVisitorRows, 'city'),
deviceBreakdown: (() => { deviceBreakdown: (() => {
const counts = { mobile: 0, desktop: 0, tablet: 0, unknown: 0 } const counts = { mobile: 0, desktop: 0, tablet: 0, unknown: 0 }
for (const row of allRangeRows) { for (const row of rangeVisitorRows) {
const d = row.device ?? 'unknown' const d = row.device ?? 'unknown'
counts[d] = (counts[d] ?? 0) + 1 counts[d] = (counts[d] ?? 0) + 1
} }
@@ -333,7 +423,7 @@ export function register(app) {
})(), })(),
topReferrers: (() => { topReferrers: (() => {
const counts = {} const counts = {}
for (const row of allRangeRows) { for (const row of rangeVisitorRows) {
if (!row.referrer) continue if (!row.referrer) continue
counts[row.referrer] = (counts[row.referrer] ?? 0) + 1 counts[row.referrer] = (counts[row.referrer] ?? 0) + 1
} }
+46 -19
View File
@@ -1,24 +1,40 @@
const passageCache = new Map() import rateLimit from 'express-rate-limit'
import { BIBLE_BOOKS } from '../../shared/bible-books.js'
const BOLLS_BOOK_MAP = { const passageCache = new Map()
genesis: 1, exodus: 2, leviticus: 3, numbers: 4, deuteronomy: 5, const PASSAGE_CACHE_MAX = 2000
joshua: 6, judges: 7, ruth: 8, '1samuel': 9, '2samuel': 10, const MAX_CHAPTER = 150
'1kings': 11, '2kings': 12, '1chronicles': 13, '2chronicles': 14, const MAX_VERSE = 176
ezra: 15, nehemiah: 16, esther: 17, job: 18, psalms: 19, proverbs: 20, const MAX_VERSES_PER_REQUEST = 60
ecclesiastes: 21, songofsolomon: 22, isaiah: 23, jeremiah: 24,
lamentations: 25, ezekiel: 26, daniel: 27, hosea: 28, joel: 29, const passageRateLimiter = rateLimit({
amos: 30, obadiah: 31, jonah: 32, micah: 33, nahum: 34, habakkuk: 35, windowMs: 60 * 1000,
zephaniah: 36, haggai: 37, zechariah: 38, malachi: 39, max: 60,
matthew: 40, mark: 41, luke: 42, john: 43, acts: 44, standardHeaders: true,
romans: 45, '1corinthians': 46, '2corinthians': 47, galatians: 48, legacyHeaders: false,
ephesians: 49, philippians: 50, colossians: 51, '1thessalonians': 52, message: { message: 'Too many passage requests. Please slow down.' },
'2thessalonians': 53, '1timothy': 54, '2timothy': 55, titus: 56, })
philemon: 57, hebrews: 58, james: 59, '1peter': 60, '2peter': 61,
'1john': 62, '2john': 63, '3john': 64, jude: 65, revelation: 66, // Keyed by lowercase, whitespace-stripped book name/alias (matching the format
} // the admin's "Bible Book" field and the /api/bible-passage `book` query param
// both use), mapped to bolls.life's numeric book id (canonical order, 1-66).
// Derived from the shared BIBLE_BOOKS list so it can't drift from the client's
// Scripture Index book map (src/App.tsx's SI_BOOK_MAP).
const BOLLS_BOOK_MAP = {}
BIBLE_BOOKS.forEach((b, index) => {
const bookNum = index + 1
for (const key of [b.name.toLowerCase(), ...(b.aliases ?? [])]) {
BOLLS_BOOK_MAP[key.replace(/\s+/g, '')] = bookNum
}
})
function parseReference(reference) { function parseReference(reference) {
const stripped = reference.replace(/^[a-z]+/i, '') // Accept a leading book name ("Titus 1:1-2", "1 John 2:3"), en/em dashes and verse-part letters ("1:13a")
const stripped = reference
.replace(/[–—]/g, '-')
.replace(/(\d)[a-z]\b/gi, '$1')
.replace(/^\s*(?:[1-3]\s*)?[a-z][a-z .]*?\s*(?=\d+:)/i, '')
.trim()
const match = stripped.match(/^(\d+):(\d+)(?:-(\d+))?$/) const match = stripped.match(/^(\d+):(\d+)(?:-(\d+))?$/)
if (!match) return null if (!match) return null
return { return {
@@ -29,7 +45,7 @@ function parseReference(reference) {
} }
export function register(app) { export function register(app) {
app.get('/api/bible-passage', async (req, res) => { app.get('/api/bible-passage', passageRateLimiter, async (req, res) => {
const book = String(req.query.book ?? '').trim().toLowerCase().replace(/[\s-]/g, '') const book = String(req.query.book ?? '').trim().toLowerCase().replace(/[\s-]/g, '')
const reference = String(req.query.reference ?? '').trim() const reference = String(req.query.reference ?? '').trim()
@@ -45,6 +61,16 @@ export function register(app) {
return return
} }
if (
parsed.chapter < 1 || parsed.chapter > MAX_CHAPTER
|| parsed.verseStart < 1 || parsed.verseEnd > MAX_VERSE
|| parsed.verseEnd < parsed.verseStart
|| parsed.verseEnd - parsed.verseStart + 1 > MAX_VERSES_PER_REQUEST
) {
res.status(400).json({ message: `Passage out of range (max ${MAX_VERSES_PER_REQUEST} verses).` })
return
}
const cacheKey = `BSB:${bookNum}:${parsed.chapter}:${parsed.verseStart}:${parsed.verseEnd}` const cacheKey = `BSB:${bookNum}:${parsed.chapter}:${parsed.verseStart}:${parsed.verseEnd}`
if (passageCache.has(cacheKey)) { if (passageCache.has(cacheKey)) {
res.json({ text: passageCache.get(cacheKey) }) res.json({ text: passageCache.get(cacheKey) })
@@ -68,6 +94,7 @@ export function register(app) {
} }
const text = results.map(v => `v. ${v.verse} ${String(v.text).trim()}`).join('\n\n') const text = results.map(v => `v. ${v.verse} ${String(v.text).trim()}`).join('\n\n')
if (passageCache.size >= PASSAGE_CACHE_MAX) passageCache.delete(passageCache.keys().next().value)
passageCache.set(cacheKey, text) passageCache.set(cacheKey, text)
res.json({ text }) res.json({ text })
} catch { } catch {
-192
View File
@@ -1,192 +0,0 @@
import { randomUUID } from 'node:crypto'
import { state } from '../state.js'
import { queueCalendarEventsWrite } from '../data.js'
import { requireAdminAuth } from '../auth.js'
const VALID_TYPES = ['general', 'recording', 'social', 'task']
const VALID_FREQS = ['none', 'weekly', 'biweekly', 'monthly']
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
const TIME_RE = /^\d{2}:\d{2}$/
function sanitizeRecurrence(raw) {
if (!raw || typeof raw !== 'object') return null
const freq = VALID_FREQS.includes(raw.freq) ? raw.freq : 'none'
if (freq === 'none') return null
const until = typeof raw.until === 'string' && DATE_RE.test(raw.until) ? raw.until : null
return { freq, until }
}
function sanitize(ev) {
const recurrence = sanitizeRecurrence(ev.recurrence)
return {
id: ev.id,
type: VALID_TYPES.includes(ev.type) ? ev.type : 'general',
title: typeof ev.title === 'string' ? ev.title.slice(0, 300) : '',
date: typeof ev.date === 'string' && DATE_RE.test(ev.date) ? ev.date : '',
startTime: typeof ev.startTime === 'string' && TIME_RE.test(ev.startTime) ? ev.startTime : undefined,
notes: typeof ev.notes === 'string' ? ev.notes.slice(0, 2000) : '',
completed: Boolean(ev.completed),
reminderDays: Number.isFinite(ev.reminderDays) && ev.reminderDays > 0 ? ev.reminderDays : 0,
reminderSentAt: typeof ev.reminderSentAt === 'string' ? ev.reminderSentAt : undefined,
recurrence: recurrence ?? undefined,
createdAt: typeof ev.createdAt === 'string' ? ev.createdAt : new Date().toISOString(),
}
}
// ── iCal helpers ─────────────────────────────────────────────────────────────
function escapeIcs(s) {
return String(s ?? '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n')
}
function foldIcsLine(line) {
const chars = [...line]
if (chars.length <= 75) return line
const parts = []
let current = ''
for (const ch of chars) {
if ((current + ch).length > 75) { parts.push(current); current = ' ' + ch }
else current += ch
}
if (current) parts.push(current)
return parts.join('\r\n')
}
function toIcsDatetime(dateStr, timeStr) {
const d = dateStr.replace(/-/g, '')
if (timeStr && TIME_RE.test(timeStr)) {
const t = timeStr.replace(':', '') + '00'
return `DTSTART:${d}T${t}`
}
return `DTSTART;VALUE=DATE:${d}`
}
function toIcsTimestamp(iso) {
try {
const d = new Date(iso)
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '')
} catch { return '' }
}
function rruleFor(recurrence) {
if (!recurrence || recurrence.freq === 'none') return null
const freqMap = { weekly: 'WEEKLY', biweekly: 'WEEKLY', monthly: 'MONTHLY' }
const freq = freqMap[recurrence.freq]
if (!freq) return null
let rule = `RRULE:FREQ=${freq}`
if (recurrence.freq === 'biweekly') rule += ';INTERVAL=2'
if (recurrence.until) rule += `;UNTIL=${recurrence.until.replace(/-/g, '')}T235959Z`
return rule
}
function generateIcal() {
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Siteforge//CalendarExport//EN',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:Verse by Verse Calendar',
]
for (const ep of (state.podcastChecklist?.episodes ?? [])) {
if (!ep.datePublished) continue
const summary = [ep.series, ep.episodeNumber != null ? `Ep ${ep.episodeNumber}` : null, ep.title].filter(Boolean).join(' · ')
const dtstart = ep.startTime ? `DTSTART:${ep.datePublished.replace(/-/g, '')}T${ep.startTime.replace(':', '')}00` : `DTSTART;VALUE=DATE:${ep.datePublished.replace(/-/g, '')}`
lines.push('BEGIN:VEVENT')
lines.push(`UID:ep-${ep.id}@siteforge`)
lines.push(dtstart)
lines.push(`SUMMARY:${escapeIcs(summary || 'Episode')}`)
if (ep.title) lines.push(`DESCRIPTION:${escapeIcs(ep.title)}`)
lines.push('END:VEVENT')
}
for (const ev of (state.calendarEvents ?? [])) {
if (!ev.date) continue
lines.push('BEGIN:VEVENT')
lines.push(`UID:ev-${ev.id}@siteforge`)
lines.push(toIcsDatetime(ev.date, ev.startTime))
lines.push(`SUMMARY:${escapeIcs(ev.title || '(Event)')}`)
if (ev.notes) lines.push(`DESCRIPTION:${escapeIcs(ev.notes)}`)
const rrule = rruleFor(ev.recurrence)
if (rrule) lines.push(rrule)
if (ev.createdAt) lines.push(`CREATED:${toIcsTimestamp(ev.createdAt)}`)
lines.push('END:VEVENT')
}
lines.push('END:VCALENDAR')
return lines.map(foldIcsLine).join('\r\n') + '\r\n'
}
// ── Routes ───────────────────────────────────────────────────────────────────
export function register(app) {
app.get('/api/admin-calendar-events', requireAdminAuth, (_req, res) => {
res.json({ events: state.calendarEvents })
})
app.get('/api/admin-calendar.ics', requireAdminAuth, (_req, res) => {
const ical = generateIcal()
res.setHeader('Content-Type', 'text/calendar; charset=utf-8')
res.setHeader('Content-Disposition', 'attachment; filename="siteforge-calendar.ics"')
res.send(ical)
})
app.post('/api/admin-calendar-events', requireAdminAuth, (req, res) => {
const { type, title, date, startTime, notes, reminderDays, recurrence } = req.body ?? {}
if (!title?.trim()) { res.status(400).json({ message: 'Title is required.' }); return }
if (!date || !DATE_RE.test(date)) { res.status(400).json({ message: 'Valid date is required.' }); return }
if (type && !VALID_TYPES.includes(type)) { res.status(400).json({ message: 'Invalid type.' }); return }
const ev = sanitize({
id: randomUUID(),
type: type ?? 'general',
title: String(title).trim(),
date,
startTime: startTime ?? undefined,
notes: notes ?? '',
completed: false,
reminderDays: reminderDays ?? 0,
recurrence: recurrence ?? null,
createdAt: new Date().toISOString(),
})
state.calendarEvents.unshift(ev)
queueCalendarEventsWrite()
res.json({ ok: true, event: ev })
})
app.patch('/api/admin-calendar-events/:id', requireAdminAuth, (req, res) => {
const { id } = req.params
let found = false
state.calendarEvents = state.calendarEvents.map(ev => {
if (ev.id !== id) return ev
found = true
const patch = {}
if (typeof req.body?.title === 'string') patch.title = req.body.title.trim().slice(0, 300)
if (typeof req.body?.date === 'string' && DATE_RE.test(req.body.date)) patch.date = req.body.date
if (typeof req.body?.startTime === 'string') patch.startTime = TIME_RE.test(req.body.startTime) ? req.body.startTime : undefined
if (req.body?.startTime === null) patch.startTime = undefined
if (typeof req.body?.notes === 'string') patch.notes = req.body.notes.trim().slice(0, 2000)
if (typeof req.body?.completed === 'boolean') patch.completed = req.body.completed
if (VALID_TYPES.includes(req.body?.type)) patch.type = req.body.type
if (Number.isFinite(req.body?.reminderDays)) patch.reminderDays = req.body.reminderDays
if ('recurrence' in (req.body ?? {})) patch.recurrence = sanitizeRecurrence(req.body.recurrence) ?? undefined
const dateChanged = patch.date && patch.date !== ev.date
const reminderChanged = 'reminderDays' in patch && patch.reminderDays !== ev.reminderDays
if (dateChanged || reminderChanged) patch.reminderSentAt = undefined
return { ...ev, ...patch }
})
if (!found) { res.status(404).json({ message: 'Event not found.' }); return }
queueCalendarEventsWrite()
res.json({ ok: true })
})
app.delete('/api/admin-calendar-events/:id', requireAdminAuth, (req, res) => {
const before = state.calendarEvents.length
state.calendarEvents = state.calendarEvents.filter(ev => ev.id !== req.params.id)
if (state.calendarEvents.length === before) { res.status(404).json({ message: 'Event not found.' }); return }
queueCalendarEventsWrite()
res.json({ ok: true })
})
}
+400 -39
View File
@@ -1,12 +1,13 @@
import { randomUUID } from 'node:crypto' import { randomUUID, timingSafeEqual } from 'node:crypto'
import { Resend } from 'resend' import { Resend } from 'resend'
import { requireAdminAuth } from '../auth.js' import { requireAdminAuth } from '../auth.js'
import { escapeHtml, splitName } from '../helpers.js' import { escapeHtml, splitName, buildAbsoluteUrl } from '../helpers.js'
import { import {
MAX_CONTACT_SUBMISSIONS, MAX_CONTACT_SUBMISSIONS,
MAX_QUESTIONS, MAX_QUESTIONS,
USE_RESEND_AUTOMATION_WELCOME, USE_RESEND_AUTOMATION_WELCOME,
DEFAULT_SEO, DEFAULT_SEO,
DATA_FILE,
} from '../config.js' } from '../config.js'
import { state } from '../state.js' import { state } from '../state.js'
import { import {
@@ -15,6 +16,8 @@ import {
queueDraftQuestionsWrite, queueDraftQuestionsWrite,
normalizeContactEmailStatus, normalizeContactEmailStatus,
normalizeMessageType, normalizeMessageType,
fireWebhooks,
loadSiteContentFile,
} from '../data.js' } from '../data.js'
import { import {
noteContactEmailCooldown, noteContactEmailCooldown,
@@ -30,6 +33,7 @@ import {
buildContactAdminNotificationTemplate, buildContactAdminNotificationTemplate,
sendResendEmailWithRetry, sendResendEmailWithRetry,
syncContactToResend, syncContactToResend,
resendRetry,
} from '../email.js' } from '../email.js'
// RFC 5322 msg-id: "<" printable-ASCII-no-whitespace ">" // RFC 5322 msg-id: "<" printable-ASCII-no-whitespace ">"
@@ -129,7 +133,6 @@ export function register(app) {
const submittedAt = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' }) const submittedAt = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })
const shouldSendWelcome = shouldSendWelcomeEmail({ subscribe }) const shouldSendWelcome = shouldSendWelcomeEmail({ subscribe })
const wantsWelcome = subscribe === true
const submission = { const submission = {
id: randomUUID(), id: randomUUID(),
submittedAt: new Date().toISOString(), submittedAt: new Date().toISOString(),
@@ -137,9 +140,9 @@ export function register(app) {
email: trimmedEmail, email: trimmedEmail,
message: trimmedMessage, message: trimmedMessage,
messageType: normalizedMessageType, messageType: normalizedMessageType,
subscribe: wantsWelcome, subscribe: shouldSendWelcome,
archived: false, archived: false,
emailStatus: normalizeContactEmailStatus(null, wantsWelcome), emailStatus: normalizeContactEmailStatus(null, shouldSendWelcome),
} }
state.contactSubmissions.unshift(submission) state.contactSubmissions.unshift(submission)
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS) state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
@@ -186,8 +189,6 @@ export function register(app) {
let publishedSiteContent = state.cachedSiteContent let publishedSiteContent = state.cachedSiteContent
if (!publishedSiteContent) { if (!publishedSiteContent) {
try { try {
const { loadSiteContentFile } = await import('../data.js')
const { DATA_FILE } = await import('../config.js')
const published = await loadSiteContentFile(DATA_FILE) const published = await loadSiteContentFile(DATA_FILE)
publishedSiteContent = published?.siteContent ?? null publishedSiteContent = published?.siteContent ?? null
} catch { publishedSiteContent = null } } catch { publishedSiteContent = null }
@@ -199,7 +200,6 @@ export function register(app) {
: DEFAULT_SEO.canonicalUrl : DEFAULT_SEO.canonicalUrl
const welcomeSubject = process.env.RESEND_WELCOME_SUBJECT ?? emailConfig.welcomeEmailSubject ?? 'Welcome to Verse by Verse with Nate' const welcomeSubject = process.env.RESEND_WELCOME_SUBJECT ?? emailConfig.welcomeEmailSubject ?? 'Welcome to Verse by Verse with Nate'
const welcomeGreetingPrefix = emailConfig.welcomeEmailGreetingPrefix?.trim() || "Glad you're here" const welcomeGreetingPrefix = emailConfig.welcomeEmailGreetingPrefix?.trim() || "Glad you're here"
const { buildAbsoluteUrl } = await import('../helpers.js')
const welcomeTemplate = buildContactWelcomeEmailTemplate({ const welcomeTemplate = buildContactWelcomeEmailTemplate({
greetingName, greetingName,
welcomeGreetingPrefix, welcomeGreetingPrefix,
@@ -311,7 +311,9 @@ export function register(app) {
|| String(req.query?.token || '').trim() || String(req.query?.token || '').trim()
|| (req.get('authorization') || '').replace(/^Bearer\s+/i, '').trim() || (req.get('authorization') || '').replace(/^Bearer\s+/i, '').trim()
if (!providedToken || providedToken !== expectedToken) { const tokenA = Buffer.from(providedToken, 'utf8')
const tokenB = Buffer.from(expectedToken, 'utf8')
if (!providedToken || tokenA.length !== tokenB.length || !timingSafeEqual(tokenA, tokenB)) {
res.status(401).json({ message: 'Unauthorized webhook.' }); return res.status(401).json({ message: 'Unauthorized webhook.' }); return
} }
@@ -354,7 +356,50 @@ export function register(app) {
app.get('/api/admin-contact-submissions', requireAdminAuth, (_req, res) => { app.get('/api/admin-contact-submissions', requireAdminAuth, (_req, res) => {
res.json({ submissions: state.contactSubmissions.slice(0, 300) }) res.json({ submissions: state.contactSubmissions })
})
app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
const { id } = req.params
const { archived, read } = req.body ?? {}
if (typeof archived !== 'boolean' && typeof read !== 'boolean') {
res.status(400).json({ message: 'archived or read must be a boolean.' }); return
}
const idx = state.contactSubmissions.findIndex(s => s.id === id)
if (idx === -1) {
res.status(404).json({ message: 'Submission not found.' }); return
}
const patch = {}
if (typeof archived === 'boolean') patch.archived = archived
if (typeof read === 'boolean') patch.read = read
state.contactSubmissions[idx] = { ...state.contactSubmissions[idx], ...patch }
queueContactSubmissionsWrite()
res.json({ ok: true, submission: state.contactSubmissions[idx] })
})
app.post('/api/admin-contact-submissions/bulk', requireAdminAuth, (req, res) => {
const { ids, action } = req.body ?? {}
const actions = ['archive', 'unarchive', 'read', 'unread', 'delete']
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 500 || !ids.every(id => typeof id === 'string') || !actions.includes(action)) {
res.status(400).json({ message: 'Provide ids (1-500) and a valid action.' }); return
}
const idSet = new Set(ids)
let count = 0
if (action === 'delete') {
const before = state.contactSubmissions.length
state.contactSubmissions = state.contactSubmissions.filter(s => !idSet.has(s.id))
count = before - state.contactSubmissions.length
} else {
state.contactSubmissions = state.contactSubmissions.map(s => {
if (!idSet.has(s.id)) return s
count += 1
if (action === 'archive') return { ...s, archived: true }
if (action === 'unarchive') return { ...s, archived: false }
return { ...s, read: action === 'read' }
})
}
queueContactSubmissionsWrite()
res.json({ ok: true, count })
}) })
app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => { app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => {
@@ -365,7 +410,7 @@ export function register(app) {
name: entry.name, name: entry.name,
email: entry.email, email: entry.email,
subscribedAt: entry.submittedAt, subscribedAt: entry.submittedAt,
source: entry.message?.startsWith('Requested') ? 'download' : 'contact-form', source: entry.source === 'resend-import' ? 'resend-import' : entry.message?.startsWith('Requested') ? 'download' : 'contact-form',
})) }))
.sort((a, b) => new Date(b.subscribedAt).getTime() - new Date(a.subscribedAt).getTime()) .sort((a, b) => new Date(b.subscribedAt).getTime() - new Date(a.subscribedAt).getTime())
res.json({ subscribers, total: subscribers.length }) res.json({ subscribers, total: subscribers.length })
@@ -387,8 +432,311 @@ export function register(app) {
res.send(csv) res.send(csv)
}) })
// ── Subscribers ⇄ Resend comparison ─────────────────────────────────────────
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
function getAppSubscribers() {
const byEmail = new Map()
for (const entry of state.contactSubmissions) {
if (entry.subscribe !== true || !entry.email) continue
const email = String(entry.email).trim().toLowerCase()
if (!byEmail.has(email)) byEmail.set(email, { name: entry.name ?? '', email })
}
return byEmail
}
async function listAllResendContacts(resend, segmentId) {
const out = []
let after
for (let page = 0; page < 50; page += 1) {
const { data, error } = await resendRetry(() => resend.contacts.list({ limit: 100, ...(segmentId ? { segmentId } : {}), ...(after ? { after } : {}) }))
if (error) throw new Error(error.message || 'Resend contacts.list failed')
const rows = data?.data ?? []
out.push(...rows)
if (!data?.has_more || rows.length === 0) break
after = rows[rows.length - 1].id
await sleep(550) // Resend allows ~2 requests/second
}
return out
}
async function buildResendComparison() {
const resend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
const segmentId = process.env.RESEND_SEGMENT_ID || null
const appSubs = getAppSubscribers()
const resendContacts = await listAllResendContacts(resend)
let segmentEmails = null
if (segmentId) {
await sleep(550)
segmentEmails = new Set((await listAllResendContacts(resend, segmentId)).map(c => String(c.email).toLowerCase()))
}
const resendByEmail = new Map(resendContacts.map(c => [String(c.email).toLowerCase(), c]))
const nameOf = c => [c.first_name, c.last_name].filter(Boolean).join(' ')
const onlyInApp = []
const unsubscribedInResend = []
const notInSegment = []
let matched = 0
for (const sub of appSubs.values()) {
const contact = resendByEmail.get(sub.email)
if (!contact) { onlyInApp.push(sub); continue }
if (contact.unsubscribed) { unsubscribedInResend.push(sub); continue }
if (segmentEmails && !segmentEmails.has(sub.email)) { notInSegment.push(sub); continue }
matched += 1
}
const onlyInResend = resendContacts
.filter(c => !appSubs.has(String(c.email).toLowerCase()) && !c.unsubscribed)
.map(c => ({ name: nameOf(c), email: String(c.email).toLowerCase() }))
return {
appCount: appSubs.size,
resendCount: resendContacts.length,
resendUnsubscribedCount: resendContacts.filter(c => c.unsubscribed).length,
segmentConfigured: Boolean(segmentId),
segmentCount: segmentEmails ? segmentEmails.size : null,
matched,
onlyInApp,
onlyInResend,
unsubscribedInResend,
notInSegment,
}
}
app.get('/api/admin-subscribers/resend-compare', requireAdminAuth, async (_req, res) => {
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
res.json(await buildResendComparison())
} catch (err) {
console.error('[subscribers] resend compare failed:', err)
res.status(502).json({ message: 'Could not read contacts from Resend. Try again in a moment.' })
}
})
app.post('/api/admin-subscribers/resend-sync', requireAdminAuth, async (req, res) => {
const { action } = req.body ?? {}
if (!['add-to-resend', 'add-to-segment', 'apply-unsubscribes'].includes(action)) {
res.status(400).json({ message: 'Unknown action.' }); return
}
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const diff = await buildResendComparison()
if (action === 'apply-unsubscribes') {
const emails = new Set(diff.unsubscribedInResend.map(s => s.email))
let changed = 0
state.contactSubmissions = state.contactSubmissions.map(entry => {
if (entry.subscribe === true && emails.has(String(entry.email ?? '').trim().toLowerCase())) {
changed += 1
return { ...entry, subscribe: false }
}
return entry
})
if (changed > 0) queueContactSubmissionsWrite()
res.json({ ok: true, done: emails.size, failed: 0 }); return
}
const resend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
const segmentId = process.env.RESEND_SEGMENT_ID || null
const targets = (action === 'add-to-resend' ? diff.onlyInApp : diff.notInSegment).slice(0, 100)
if (action === 'add-to-segment' && !segmentId) {
res.status(400).json({ message: 'RESEND_SEGMENT_ID is not configured.' }); return
}
let done = 0
let failed = 0
for (const target of targets) {
try {
if (action === 'add-to-resend') {
const { firstName, lastName } = splitName(target.name)
const { error } = await resendRetry(() => resend.contacts.create({
email: target.email, firstName, lastName, unsubscribed: false,
...(segmentId ? { segments: [{ id: segmentId }] } : {}),
}))
if (error) throw new Error(error.message)
} else {
const { error } = await resendRetry(() => resend.contacts.segments.add({ email: target.email, segmentId }))
if (error) throw new Error(error.message)
}
done += 1
} catch (err) {
failed += 1
console.error(`[subscribers] resend sync failed for ${target.email}:`, err?.message ?? err)
}
await sleep(550)
}
const total = action === 'add-to-resend' ? diff.onlyInApp.length : diff.notInSegment.length
res.json({ ok: true, done, failed, remaining: Math.max(total - targets.length, 0) })
} catch (err) {
console.error('[subscribers] resend sync failed:', err)
res.status(502).json({ message: 'Resend sync failed. Try again in a moment.' })
}
})
app.get('/api/admin-broadcast/templates', requireAdminAuth, async (_req, res) => {
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const templates = []
let after
for (let page = 0; page < 5; page += 1) {
const { data, error } = await resendRetry(() => resend.templates.list({ limit: 100, ...(after ? { after } : {}) }))
if (error) throw new Error(error.message)
templates.push(...(data?.data ?? []))
if (!data?.has_more || !data.data?.length) break
after = data.data[data.data.length - 1].id
}
res.json({
templates: templates
.map(t => ({ id: t.id, name: t.name, status: t.status, updatedAt: t.updated_at, publishedAt: t.published_at }))
.sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? ''))),
})
} catch (err) {
console.error('[admin-broadcast] template list failed:', err)
res.status(502).json({ message: 'Could not load templates from Resend.' })
}
})
app.get('/api/admin-broadcast/templates/:id', requireAdminAuth, async (req, res) => {
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const { data, error } = await resendRetry(() => resend.templates.get(req.params.id))
if (error || !data) {
res.status(404).json({ message: error?.message || 'Template not found.' }); return
}
// Resend variables look like {{{KEY}}}; fill in their saved fallback values, but keep name for per-subscriber personalization.
let html = data.html ?? ''
for (const v of data.variables ?? []) {
if (/^(first_?name|name)$/i.test(v.key)) continue
if (v.fallback_value === null || v.fallback_value === undefined) continue
html = html.replace(new RegExp(`\\{\\{\\{\\s*${v.key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\}\\}\\}`, 'g'), escapeHtml(String(v.fallback_value)))
}
res.json({
id: data.id,
name: data.name,
subject: data.subject ?? '',
html,
hasUnsubscribeLink: /\{\{\{\s*RESEND_UNSUBSCRIBE_URL\s*\}\}\}/i.test(html),
unresolved: [...new Set([...html.matchAll(/\{\{\{\s*([A-Za-z0-9_]+)\s*\}\}\}/g)].map(m => m[1]))]
.filter(k => !/^(RESEND_UNSUBSCRIBE_URL|first_?name|name)$/i.test(k)),
})
} catch (err) {
console.error('[admin-broadcast] template get failed:', err)
res.status(502).json({ message: 'Could not load that template from Resend.' })
}
})
// Import chosen Resend-only contacts into the app's subscriber list (explicit, per-person opt-in by the admin).
app.post('/api/admin-subscribers/import-from-resend', requireAdminAuth, async (req, res) => {
const { emails } = req.body ?? {}
if (!Array.isArray(emails) || emails.length === 0 || emails.length > 200 || !emails.every(e => typeof e === 'string')) {
res.status(400).json({ message: 'Choose between 1 and 200 contacts to import.' }); return
}
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const diff = await buildResendComparison()
const allowed = new Map(diff.onlyInResend.map(c => [c.email, c]))
const now = new Date().toISOString()
const created = []
for (const raw of emails) {
const email = raw.trim().toLowerCase()
const contact = allowed.get(email)
if (!contact) continue // not active in Resend, or already a subscriber here
created.push({
id: randomUUID(),
submittedAt: now,
name: contact.name || email,
email,
message: 'Imported from Resend audience',
messageType: 'general',
subscribe: true,
archived: true,
read: true,
source: 'resend-import',
})
}
if (created.length > 0) {
state.contactSubmissions = [...created, ...state.contactSubmissions]
queueContactSubmissionsWrite()
}
res.json({ ok: true, imported: created.length, skipped: emails.length - created.length })
} catch (err) {
console.error('[subscribers] import from resend failed:', err)
res.status(502).json({ message: 'Import failed. Try again in a moment.' })
}
})
// ── Broadcast Email: sent as a real Resend Broadcast to the Resend segment ──────
const UNSUB_FOOTER = '<p style="margin:32px 0 0;font-size:12px;color:#888;text-align:center;"><a href="{{{RESEND_UNSUBSCRIBE_URL}}}" style="color:#888;">Unsubscribe</a></p>'
// App-side {{name}} becomes Resend's per-contact variable; guarantee an unsubscribe link exists.
function toResendBroadcastHtml(html) {
let out = html.replace(/\{\{\{?\s*(?:first_?name|name)\s*\}?\}\}/gi, '{{{FIRST_NAME|Friend}}}')
if (!/RESEND_UNSUBSCRIBE_URL/i.test(out)) {
out = /<\/body>/i.test(out) ? out.replace(/<\/body>/i, `${UNSUB_FOOTER}</body>`) : `${out}\n${UNSUB_FOOTER}`
}
return out
}
app.get('/api/admin-broadcast/audience', requireAdminAuth, async (_req, res) => {
const segmentId = process.env.RESEND_SEGMENT_ID
if (!process.env.RESEND_API_KEY || !segmentId) {
res.json({ segmentConfigured: false, count: null }); return
}
try {
const resend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
const contacts = await listAllResendContacts(resend, segmentId)
res.json({ segmentConfigured: true, count: contacts.filter(c => !c.unsubscribed).length })
} catch (err) {
console.error('[admin-broadcast] audience count failed:', err)
res.status(502).json({ message: 'Could not read the Resend segment.' })
}
})
app.post('/api/admin-broadcast/test', requireAdminAuth, async (req, res) => {
const { subject, html, to } = req.body ?? {}
if (typeof subject !== 'string' || !subject.trim() || typeof html !== 'string' || !html.trim()) {
res.status(400).json({ message: 'Subject and body are required.' }); return
}
if (typeof to !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to.trim())) {
res.status(400).json({ message: 'Enter a valid test email address.' }); return
}
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const preview = toResendBroadcastHtml(html)
.replace(/\{\{\{\s*FIRST_NAME\s*\|\s*([^}]*?)\s*\}\}\}/gi, (_m, fallback) => escapeHtml(fallback || 'Friend'))
.replace(/\{\{\{\s*RESEND_UNSUBSCRIBE_URL\s*\}\}\}/gi, '#')
const resend = new Resend(process.env.RESEND_API_KEY)
const { error } = await resend.emails.send({
from: getResendFromAddress(),
to: to.trim(),
replyTo: getResendReplyToAddress(),
subject: `[TEST] ${subject.trim()}`,
html: preview,
})
if (error) throw new Error(error.message)
res.json({ ok: true })
} catch (err) {
console.error('[admin-broadcast] test send failed:', err)
res.status(502).json({ message: 'Test email failed to send.' })
}
})
app.post('/api/admin-broadcast', requireAdminAuth, async (req, res) => { app.post('/api/admin-broadcast', requireAdminAuth, async (req, res) => {
const { subject, html } = req.body ?? {} const { subject, html, previewText, tagNewEpisode, scheduledAt } = req.body ?? {}
if (!subject || typeof subject !== 'string' || !subject.trim()) { if (!subject || typeof subject !== 'string' || !subject.trim()) {
res.status(400).json({ message: 'Subject is required.' }); return res.status(400).json({ message: 'Subject is required.' }); return
} }
@@ -398,38 +746,51 @@ export function register(app) {
if (!process.env.RESEND_API_KEY) { if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
} }
const segmentId = process.env.RESEND_SEGMENT_ID
const seen = new Set() if (!segmentId) {
const recipients = state.contactSubmissions res.status(500).json({ message: 'RESEND_SEGMENT_ID is not configured, so there is no segment to send to.' }); return
.filter(e => e.subscribe === true && e.email && !seen.has(e.email) && seen.add(e.email)) }
.map(e => ({ name: e.name ?? '', email: e.email })) let scheduled
if (typeof scheduledAt === 'string' && scheduledAt.trim()) {
if (recipients.length === 0) { const when = Date.parse(scheduledAt)
res.status(400).json({ message: 'No subscribers to send to.' }); return if (!Number.isFinite(when) || when < Date.now() + 60 * 1000) {
res.status(400).json({ message: 'Schedule time must be at least a minute in the future.' }); return
}
scheduled = new Date(when).toISOString()
} }
const resend = new Resend(process.env.RESEND_API_KEY) try {
const from = getResendFromAddress() const resend = new Resend(process.env.RESEND_API_KEY)
const replyTo = getResendReplyToAddress()
const BATCH = 50
let sent = 0
let failed = 0
for (let i = 0; i < recipients.length; i += BATCH) { let topicId
const batch = recipients.slice(i, i + BATCH) if (tagNewEpisode === true) {
const emails = batch.map(r => ({ const { data: topics, error: topicsError } = await resendRetry(() => resend.topics.list())
from, if (topicsError) throw new Error(topicsError.message)
to: r.email, topicId = (topics?.data ?? []).find(t => String(t.name || '').trim().toLowerCase() === 'new episode')?.id
reply_to: replyTo, if (!topicId) {
res.status(400).json({ message: 'No "New Episode" topic exists in Resend.' }); return
}
}
const { data, error } = await resendRetry(() => resend.broadcasts.create({
name: subject.trim().slice(0, 120),
segmentId,
from: getResendFromAddress(),
replyTo: getResendReplyToAddress(),
subject: subject.trim(), subject: subject.trim(),
html: html.replace(/\{\{name\}\}/g, escapeHtml(r.name || 'Friend')), html: toResendBroadcastHtml(html),
...(typeof previewText === 'string' && previewText.trim() ? { previewText: previewText.trim().slice(0, 200) } : {}),
...(topicId ? { topicId } : {}),
send: true,
...(scheduled ? { scheduledAt: scheduled } : {}),
})) }))
try { if (error || !data?.id) throw new Error(error?.message || 'Resend did not return a broadcast id.')
const { error } = await resend.batch.send(emails)
if (error) { failed += batch.length } else { sent += batch.length }
} catch { failed += batch.length }
}
res.json({ ok: true, sent, failed, total: recipients.length }) console.log(`[admin-broadcast] ${scheduled ? 'scheduled' : 'sent'} Resend broadcast ${data.id}: ${subject.trim()}`)
res.json({ ok: true, broadcastId: data.id, scheduledAt: scheduled ?? null })
} catch (err) {
console.error('[admin-broadcast] send failed:', err)
res.status(502).json({ message: `Resend rejected the broadcast: ${err instanceof Error ? err.message : 'unknown error'}` })
}
}) })
} }
+87
View File
@@ -145,6 +145,18 @@ export function register(app) {
const customResources = Array.isArray(siteContent.customLinks) const customResources = Array.isArray(siteContent.customLinks)
? siteContent.customLinks.filter(link => link?.placement === 'resources') ? siteContent.customLinks.filter(link => link?.placement === 'resources')
: [] : []
// Companion study guides: the second series' guide, and guides kept on finished books
if (id === 'guide:second') {
const guide = siteContent.secondSeries?.guide
const guideUrl = sanitizeUrl(guide?.downloadUrl)
return guideUrl ? { label: guide.title, url: guideUrl } : null
}
if (id.startsWith('guide:book:')) {
const bookId = id.slice('guide:book:'.length)
const book = Array.isArray(siteContent.finishedBooks) ? siteContent.finishedBooks.find(item => item?.id === bookId) : null
const bookGuideUrl = sanitizeUrl(book?.guide?.downloadUrl)
return bookGuideUrl ? { label: book.guide.title, url: bookGuideUrl } : null
}
if (id.startsWith('custom:')) { if (id.startsWith('custom:')) {
const customId = id.slice('custom:'.length) const customId = id.slice('custom:'.length)
const match = customResources.find(link => link.id === customId) const match = customResources.find(link => link.id === customId)
@@ -210,6 +222,81 @@ export function register(app) {
} }
}) })
// Collects a visitor's details, then hands back the Amazon / Apple Books link for a guide or book.
// The destination always comes from the saved site content, never from the request.
app.post('/api/store-link', studyDownloadRateLimit, async (req, res) => {
try {
const { source, store, firstName, lastName, email, subscribe, _honey } = req.body ?? {}
if (_honey) { res.json({ ok: true }); return }
if (typeof source !== 'string' || !source || (store !== 'amazon' && store !== 'apple')) {
res.status(400).json({ message: 'That link is not available.' }); return
}
if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
res.status(400).json({ message: 'First name is required.' }); return
}
if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) {
res.status(400).json({ message: 'Last name is required.' }); return
}
if (!email || typeof email !== 'string' || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email.trim())) {
res.status(400).json({ message: 'A valid email address is required.' }); return
}
const published = await loadSiteContentFile(DATA_FILE)
const content = published?.siteContent ?? {}
const pick = (amazon, apple) => (store === 'amazon' ? amazon : apple)
let label = ''
let url = ''
if (source === 'page:primary-guide') {
label = content.studyGuideTitle
url = pick(content.studyGuideUrl, content.studyGuideAppleBooksUrl)
} else if (source === 'page:second-guide') {
const guide = content.secondSeries?.guide
label = guide?.title
url = pick(guide?.amazonUrl, guide?.appleBooksUrl)
} else if (source.startsWith('page:book-guide--')) {
const id = source.slice('page:book-guide--'.length)
const guide = (Array.isArray(content.finishedBooks) ? content.finishedBooks : []).find(b => b?.id === id)?.guide
label = guide?.title
url = pick(guide?.amazonUrl, guide?.appleBooksUrl)
} else if (source.startsWith('page:custom--')) {
const id = source.slice('page:custom--'.length)
const link = (content.customLinks ?? []).find(l => l?.id === id && l.placement === 'resources')
label = link?.label
url = pick(link?.amazonUrl, link?.appleBooksUrl)
} else if (source.startsWith('book:')) {
const id = source.slice('book:'.length)
const book = (content.customLinks ?? []).find(l => l?.id === id && l.placement === 'books')
label = book?.label
url = pick(book?.amazonUrl || book?.url, book?.appleBooksUrl)
}
const destination = sanitizeUrl(url)
if (!destination) { res.status(404).json({ message: 'That link is not available.' }); return }
const name = `${firstName.trim()} ${lastName.trim()}`.trim()
const trimmedEmail = email.trim()
const wantsSubscribe = subscribe !== false
const storeName = store === 'amazon' ? 'Amazon' : 'Apple Books'
addContactSubmission({
name,
email: trimmedEmail,
message: `Requested ${storeName} link: ${label || source}`,
messageType: 'general',
subscribe: wantsSubscribe,
})
if (wantsSubscribe) await syncContactToResend(name, trimmedEmail)
incrementDownloadCount(`store:${source}:${store}`)
res.json({ ok: true, url: destination })
} catch (err) {
console.error('[store-link] request error:', err)
res.status(500).json({ message: 'Failed to process your request. Please try again.' })
}
})
app.get('/api/admin-download-stats', requireAdminAuth, (_req, res) => { app.get('/api/admin-download-stats', requireAdminAuth, (_req, res) => {
res.json({ counts: state.downloadCounts }) res.json({ counts: state.downloadCounts })
}) })
+128 -21
View File
@@ -3,28 +3,69 @@ import { requireAdminAuth } from '../auth.js'
import { state } from '../state.js' import { state } from '../state.js'
import { queueEpisodeScriptsWrite } from '../data.js' import { queueEpisodeScriptsWrite } from '../data.js'
import { MAX_EPISODE_SCRIPT_LENGTH } from '../config.js' import { MAX_EPISODE_SCRIPT_LENGTH } from '../config.js'
import { fetchAllEpisodes } from './episodes.js'
import { scriptKey, scriptEpisodeNumber } from '../episode-script-keys.js'
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
// Word-boundary containment so "sin" doesn't match inside "using"/"resin".
function containsWord(haystackLower, word) {
return new RegExp(`\\b${escapeRegExp(word)}\\b`, 'i').test(haystackLower)
}
/** /**
* Simple whitespace-normalised search over episode script text. * Whitespace-normalised search over episode script text, with word-boundary
* Returns { episodeNumber, title, filename, snippet, score } for matches. * matching, an exact-phrase score boost, and a "season N" browse mode.
* Returns { episodeNumber, title, filename, season, snippet, score } for matches.
*/ */
function searchScripts(query) { function searchScripts(query) {
if (!query || query.trim().length < 2) return [] if (!query || query.trim().length < 2) return []
const q = query.trim().toLowerCase() const q = query.trim()
const words = q.split(/\s+/).filter(w => w.length >= 2) const qLower = q.toLowerCase()
// "season N" browses every script tagged with that season, no text match needed.
const seasonQuery = /^season\s+(\d+)$/i.exec(q)
if (seasonQuery) {
const season = seasonQuery[1]
return Object.entries(state.episodeScripts)
.filter(([, entry]) => String(entry.season ?? '') === season)
.map(([key, entry]) => {
const episodeNumber = scriptEpisodeNumber(key, entry)
return {
key,
episodeNumber,
title: entry.title ?? `Episode ${episodeNumber}`,
filename: entry.filename ?? '',
season: entry.season ?? null,
snippet: '',
score: 1,
}
})
.sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber))
.slice(0, 8)
}
const words = qLower.split(/\s+/).filter(w => w.length >= 2)
if (words.length === 0) return []
const results = [] const results = []
for (const [episodeNumber, entry] of Object.entries(state.episodeScripts)) { for (const [key, entry] of Object.entries(state.episodeScripts)) {
if (!entry?.text) continue if (!entry?.text) continue
const episodeNumber = scriptEpisodeNumber(key, entry)
const text = entry.text.toLowerCase() const text = entry.text.toLowerCase()
// Count how many words appear in the text const matchedWords = words.filter(w => containsWord(text, w))
const matchCount = words.filter(w => text.includes(w)).length if (matchedWords.length === 0) continue
if (matchCount === 0) continue
// Exact multi-word phrase matches rank above partial word-overlap matches.
const isExactPhrase = words.length > 1 && text.includes(qLower)
const score = isExactPhrase ? 1 : matchedWords.length / words.length
// Find a snippet around the first matching word // Find a snippet around the first matching word
const firstWord = words.find(w => text.includes(w)) const firstWord = matchedWords[0]
const idx = firstWord ? text.indexOf(firstWord) : 0 const idx = text.indexOf(firstWord)
const start = Math.max(0, idx - 80) const start = Math.max(0, idx - 80)
const end = Math.min(entry.text.length, idx + 180) const end = Math.min(entry.text.length, idx + 180)
let snippet = entry.text.slice(start, end).replace(/\s+/g, ' ').trim() let snippet = entry.text.slice(start, end).replace(/\s+/g, ' ').trim()
@@ -32,11 +73,13 @@ function searchScripts(query) {
if (end < entry.text.length) snippet = snippet + '…' if (end < entry.text.length) snippet = snippet + '…'
results.push({ results.push({
key,
episodeNumber, episodeNumber,
title: entry.title ?? `Episode ${episodeNumber}`, title: entry.title ?? `Episode ${episodeNumber}`,
filename: entry.filename ?? '', filename: entry.filename ?? '',
season: entry.season ?? null,
snippet, snippet,
score: matchCount / words.length, // 0–1: fraction of query words found score,
}) })
} }
@@ -53,19 +96,23 @@ export function register(app) {
// Admin: list all uploaded scripts // Admin: list all uploaded scripts
app.get('/api/admin-episode-scripts', requireAdminAuth, (_req, res) => { app.get('/api/admin-episode-scripts', requireAdminAuth, (_req, res) => {
const list = Object.entries(state.episodeScripts).map(([episodeNumber, entry]) => ({ const list = Object.entries(state.episodeScripts).map(([key, entry]) => ({
episodeNumber, key,
episodeNumber: scriptEpisodeNumber(key, entry),
title: entry.title ?? `Episode ${episodeNumber}`, title: entry.title ?? `Episode ${episodeNumber}`,
filename: entry.filename ?? '', filename: entry.filename ?? '',
season: entry.season ?? null,
uploadedAt: entry.uploadedAt ?? null, uploadedAt: entry.uploadedAt ?? null,
wordCount: entry.text ? entry.text.split(/\s+/).filter(Boolean).length : 0, wordCount: entry.text ? entry.text.split(/\s+/).filter(Boolean).length : 0,
})) }))
list.sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber)) list.sort((a, b) => (Number(a.season) || 0) - (Number(b.season) || 0) || Number(a.episodeNumber) - Number(b.episodeNumber))
res.json({ scripts: list }) res.json({ scripts: list })
}) })
// Admin: upload a docx script for an episode // Admin: upload a docx script for an episode
app.post('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, async (req, res) => { app.post('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, async (req, res, next) => {
// "backfill-seasons" is its own route below; don't let this one swallow it
if (req.params.episodeNumber === 'backfill-seasons') { next(); return }
const episodeNumber = req.params.episodeNumber?.trim() const episodeNumber = req.params.episodeNumber?.trim()
if (!episodeNumber || !/^\d+$/.test(episodeNumber)) { if (!episodeNumber || !/^\d+$/.test(episodeNumber)) {
res.status(400).json({ message: 'Episode number must be a positive integer.' }); return res.status(400).json({ message: 'Episode number must be a positive integer.' }); return
@@ -74,6 +121,8 @@ export function register(app) {
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : '' const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
const filename = typeof req.body?.filename === 'string' ? req.body.filename.trim() : `episode-${episodeNumber}.docx` const filename = typeof req.body?.filename === 'string' ? req.body.filename.trim() : `episode-${episodeNumber}.docx`
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : `Episode ${episodeNumber}` const title = typeof req.body?.title === 'string' ? req.body.title.trim() : `Episode ${episodeNumber}`
const seasonRaw = typeof req.body?.season === 'string' ? req.body.season.trim() : (typeof req.body?.season === 'number' ? String(req.body.season) : '')
const season = /^\d+$/.test(seasonRaw) ? seasonRaw : null
if (!dataUrl.startsWith('data:')) { if (!dataUrl.startsWith('data:')) {
res.status(400).json({ message: 'dataUrl must be a valid data URL.' }); return res.status(400).json({ message: 'dataUrl must be a valid data URL.' }); return
@@ -105,9 +154,16 @@ export function register(app) {
text = text.slice(0, MAX_EPISODE_SCRIPT_LENGTH) text = text.slice(0, MAX_EPISODE_SCRIPT_LENGTH)
} }
state.episodeScripts[episodeNumber] = { const key = scriptKey(season, episodeNumber)
// A script with no season that this upload replaces would otherwise linger as a duplicate
if (season && state.episodeScripts[episodeNumber] && !state.episodeScripts[episodeNumber].season) {
delete state.episodeScripts[episodeNumber]
}
state.episodeScripts[key] = {
episodeNumber,
title, title,
filename, filename,
season,
text, text,
uploadedAt: new Date().toISOString(), uploadedAt: new Date().toISOString(),
wordCount: text.split(/\s+/).filter(Boolean).length, wordCount: text.split(/\s+/).filter(Boolean).length,
@@ -116,18 +172,69 @@ export function register(app) {
res.json({ res.json({
ok: true, ok: true,
key,
episodeNumber, episodeNumber,
wordCount: state.episodeScripts[episodeNumber].wordCount, season,
wordCount: state.episodeScripts[key].wordCount,
}) })
}) })
// Admin: fill in season numbers for existing scripts from the podcast RSS feed
// (matches by episode number; never overwrites a season that's already set)
app.post('/api/admin-episode-scripts/backfill-seasons', requireAdminAuth, async (_req, res) => {
let episodes
try {
episodes = await fetchAllEpisodes()
} catch (err) {
console.error('[episode-scripts] backfill: RSS fetch failed:', err.message)
res.status(502).json({ message: 'Could not fetch the podcast RSS feed to look up seasons.' })
return
}
const normalize = value => String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
// Episode numbers can repeat across seasons, so keep every season an episode number appears in.
const feedByEpisodeNumber = new Map()
for (const ep of episodes) {
if (!ep.episode || !ep.season) continue
const list = feedByEpisodeNumber.get(ep.episode) ?? []
list.push({ season: String(ep.season), title: normalize(ep.title) })
feedByEpisodeNumber.set(ep.episode, list)
}
let updated = 0
const stillMissing = []
for (const [key, entry] of Object.entries({ ...state.episodeScripts })) {
if (entry.season) continue
const episodeNumber = scriptEpisodeNumber(key, entry)
const candidates = feedByEpisodeNumber.get(episodeNumber) ?? []
let match = candidates.length === 1 ? candidates[0] : undefined
// Several seasons share this episode number: fall back to matching the title
if (!match && candidates.length > 1) {
const byTitle = candidates.filter(c => c.title && c.title === normalize(entry.title))
if (byTitle.length === 1) match = byTitle[0]
}
const newKey = match ? scriptKey(match.season, episodeNumber) : ''
if (!match || (newKey !== key && state.episodeScripts[newKey])) {
stillMissing.push(episodeNumber)
continue
}
delete state.episodeScripts[key]
state.episodeScripts[newKey] = { ...entry, season: match.season, episodeNumber }
updated += 1
}
if (updated > 0) queueEpisodeScriptsWrite()
res.json({ ok: true, updated, stillMissing })
})
// Admin: delete a script // Admin: delete a script
app.delete('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, (req, res) => { app.delete('/api/admin-episode-scripts/:key', requireAdminAuth, (req, res) => {
const episodeNumber = req.params.episodeNumber?.trim() const key = req.params.key?.trim()
if (!state.episodeScripts[episodeNumber]) { if (!key || !state.episodeScripts[key]) {
res.status(404).json({ message: 'Script not found.' }); return res.status(404).json({ message: 'Script not found.' }); return
} }
delete state.episodeScripts[episodeNumber] delete state.episodeScripts[key]
queueEpisodeScriptsWrite() queueEpisodeScriptsWrite()
res.json({ ok: true }) res.json({ ok: true })
}) })
+16 -45
View File
@@ -1,45 +1,11 @@
import { sanitizeUrl } from '../study-helpers.js' import { sanitizeUrl } from '../study-helpers.js'
import { parseRssItems } from '../rss-helpers.js'
const RSS_FEED_URL = 'https://anchor.fm/s/11068d290/podcast/rss' const RSS_FEED_URL = 'https://anchor.fm/s/11068d290/podcast/rss'
let episodesCache = null let episodesCache = null
let episodesCacheAt = 0 let episodesCacheAt = 0
let rawXmlCache = null let rawXmlCache = null
const EPISODES_CACHE_TTL = 30 * 60 * 1000 const EPISODES_CACHE_TTL = 10 * 60 * 1000
function extractCdata(raw) {
const cdata = /^<!\[CDATA\[([\s\S]*?)\]\]>$/.exec(raw.trim())
return cdata ? cdata[1].trim() : raw.trim()
}
function parseRssItems(xml, limit = Infinity) {
const items = []
const itemRegex = /<item>([\s\S]*?)<\/item>/g
let match
while ((match = itemRegex.exec(xml)) !== null && items.length < limit) {
const block = match[1]
const titleRaw = /<title>([\s\S]*?)<\/title>/.exec(block)?.[1] ?? ''
const title = extractCdata(titleRaw)
if (!title) continue
const pubDate = (/<pubDate>([\s\S]*?)<\/pubDate>/.exec(block)?.[1] ?? '').trim()
const guidRaw = /<guid[^>]*>([\s\S]*?)<\/guid>/.exec(block)?.[1] ?? ''
const guid = extractCdata(guidRaw)
const enclosureUrl = /<enclosure[^>]+url="([^"]+)"/.exec(block)?.[1] ?? ''
const link = guid.startsWith('http') ? guid : enclosureUrl
const descRaw = /<description>([\s\S]*?)<\/description>/.exec(block)?.[1] ?? ''
const descText = extractCdata(descRaw).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
const duration = (/<itunes:duration>([\s\S]*?)<\/itunes:duration>/.exec(block)?.[1] ?? '').trim()
const episode = (/<itunes:episode>([\s\S]*?)<\/itunes:episode>/.exec(block)?.[1] ?? '').trim()
const season = (/<itunes:season>([\s\S]*?)<\/itunes:season>/.exec(block)?.[1] ?? '').trim()
items.push({
title, pubDate, link,
audioUrl: enclosureUrl,
description: descText.slice(0, 220) + (descText.length > 220 ? '…' : ''),
duration, episode, season,
})
}
return items
}
export async function fetchAllEpisodes() { export async function fetchAllEpisodes() {
const now = Date.now() const now = Date.now()
@@ -48,15 +14,20 @@ export async function fetchAllEpisodes() {
} }
const controller = new AbortController() const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 8000) const timeout = setTimeout(() => controller.abort(), 8000)
const response = await fetch(RSS_FEED_URL, { signal: controller.signal }) try {
clearTimeout(timeout) const response = await fetch(RSS_FEED_URL, { signal: controller.signal })
if (!response.ok) throw new Error(`RSS fetch failed: ${response.status}`) clearTimeout(timeout)
const xml = await response.text() if (!response.ok) throw new Error(`RSS fetch failed: ${response.status}`)
rawXmlCache = xml const xml = await response.text()
const episodes = parseRssItems(xml) rawXmlCache = xml
episodesCache = episodes const episodes = parseRssItems(xml)
episodesCacheAt = now episodesCache = episodes
return episodes episodesCacheAt = now
return episodes
} catch (err) {
clearTimeout(timeout)
throw err
}
} }
export async function fetchRawFeedXml() { export async function fetchRawFeedXml() {
+22 -2
View File
@@ -6,6 +6,26 @@ import { MAX_CONTACT_SUBMISSIONS } from '../config.js'
const MESSAGE_ID_RE = /^<[\x21-\x7E]+>$/ const MESSAGE_ID_RE = /^<[\x21-\x7E]+>$/
const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024 // 5 MB total per email const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024 // 5 MB total per email
const CP1252_EXTRA = {
'€': 0x80, '‚': 0x82, 'ƒ': 0x83, '„': 0x84, '…': 0x85, '†': 0x86, '‡': 0x87, 'ˆ': 0x88, '‰': 0x89, 'Š': 0x8a,
'‹': 0x8b, 'Œ': 0x8c, 'Ž': 0x8e, '‘': 0x91, '’': 0x92, '“': 0x93, '”': 0x94, '•': 0x95, '–': 0x96, '—': 0x97,
'˜': 0x98, '™': 0x99, 'š': 0x9a, '›': 0x9b, 'œ': 0x9c, 'ž': 0x9e, 'Ÿ': 0x9f,
}
// Text that was UTF-8 but got decoded as Latin-1/Windows-1252 ("’", "é", "ð") — undo that.
function repairMojibake(text) {
if (typeof text !== 'string' || !/[ÃÂâð][\u0080-\u00ff€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ]/.test(text)) return text
const bytes = []
for (const ch of text) {
const code = ch.codePointAt(0)
if (code <= 0xff) bytes.push(code)
else if (CP1252_EXTRA[ch] !== undefined) bytes.push(CP1252_EXTRA[ch])
else return text
}
const fixed = Buffer.from(bytes).toString('utf8')
return fixed.includes('\ufffd') ? text : fixed
}
function decodeHtmlBody(raw) { function decodeHtmlBody(raw) {
if (typeof raw !== 'string' || !raw.trim()) return null if (typeof raw !== 'string' || !raw.trim()) return null
const s = raw.trim() const s = raw.trim()
@@ -96,7 +116,7 @@ export function register(app) {
} }
} }
const subjectStr = typeof subject === 'string' ? subject.trim() : '' const subjectStr = typeof subject === 'string' ? repairMojibake(subject.trim()) : ''
const threadId = resolveThreadId(fromEmail, normalizeSubject(subjectStr), normalizedInReplyTo) const threadId = resolveThreadId(fromEmail, normalizeSubject(subjectStr), normalizedInReplyTo)
const submission = { const submission = {
@@ -105,7 +125,7 @@ export function register(app) {
submittedAt: (typeof date === 'string' || typeof date === 'number') && Number.isFinite(Date.parse(date)) ? new Date(date).toISOString() : new Date().toISOString(), submittedAt: (typeof date === 'string' || typeof date === 'number') && Number.isFinite(Date.parse(date)) ? new Date(date).toISOString() : new Date().toISOString(),
name: fromName || fromEmail, name: fromName || fromEmail,
email: fromEmail, email: fromEmail,
message: [subjectStr ? `Subject: ${subjectStr}` : '', body ?? ''].filter(Boolean).join('\n\n'), message: [subjectStr ? `Subject: ${subjectStr}` : '', repairMojibake(typeof body === 'string' ? body : '')].filter(Boolean).join('\n\n'),
htmlBody: decodeHtmlBody(htmlBody), htmlBody: decodeHtmlBody(htmlBody),
messageType: 'general', messageType: 'general',
subscribe: false, subscribe: false,
+266
View File
@@ -0,0 +1,266 @@
import { randomUUID } from 'node:crypto'
import { Resend } from 'resend'
import { requireAdminAuth } from '../auth.js'
import { state } from '../state.js'
import { queueNewsletterIssuesWrite } from '../data.js'
import { escapeHtml } from '../helpers.js'
import { resendRetry } from '../email.js'
const BASE = 'https://versebyversewithnate.us'
const NEW_EPISODE_TOPIC_NAME = 'new episode'
function truncate(str, max) {
const clean = String(str || '').trim()
if (clean.length <= max) return clean
return `${clean.slice(0, max - 1).trimEnd()}…`
}
function stripSubjectPrefix(subject) {
return String(subject || '').replace(/^New Episode:?\s*-?\s*/i, '').trim()
}
function slugify(str) {
return String(str || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80)
}
function uniqueSlug(base) {
const root = slugify(base) || 'issue'
let candidate = root
let n = 2
while (state.newsletterIssues.some(i => i.slug === candidate)) {
candidate = `${root}-${n}`
n += 1
}
return candidate
}
function extractDescription(text, previewText) {
if (previewText && previewText.trim()) return truncate(previewText, 200)
const paragraphs = String(text || '')
.split(/\n{2,}/)
.map(p => p.replace(/\s+/g, ' ').trim())
.filter(Boolean)
const candidate = paragraphs.find(p => p.length > 60 && !/^[◆✦✍✎▶]/.test(p) && p !== p.toUpperCase())
return truncate(candidate || paragraphs[paragraphs.length - 1] || '', 200)
}
// Strip the raw {{{RESEND_UNSUBSCRIBE_URL}}} placeholder link — it only resolves inside an actual sent email.
function stripUnsubscribeLink(html) {
return String(html || '').replace(
/<p[^>]*>\s*<a[^>]*href="\{\{\{RESEND_UNSUBSCRIBE_URL\}\}\}"[\s\S]*?<\/a>\s*<\/p>/i,
'',
)
}
function buildIssuePageHtml(issue) {
const canonical = `${BASE}/newsletter/${encodeURIComponent(issue.slug)}`
const ogImage = `${BASE}/images/banner.png`
const title = escapeHtml(issue.title)
const description = escapeHtml(issue.description)
const metaBlock = `
<title>${title} — Verse by Verse with Nate</title>
<meta name="description" content="${description}"/>
<meta property="og:type" content="article"/>
<meta property="og:site_name" content="Verse by Verse with Nate"/>
<meta property="og:url" content="${escapeHtml(canonical)}"/>
<meta property="og:title" content="${title}"/>
<meta property="og:description" content="${description}"/>
<meta property="og:image" content="${escapeHtml(ogImage)}"/>
<meta property="og:image:alt" content="${title}"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="${title}"/>
<meta name="twitter:description" content="${description}"/>
<meta name="twitter:image" content="${escapeHtml(ogImage)}"/>
<link rel="canonical" href="${escapeHtml(canonical)}"/>
<style>
/* The source HTML is a Resend/React Email template built for a narrow email-client
viewport: its outer wrapper table uses align="left" (which browsers translate to
float: left) and relies on the surrounding white to be invisible in a ~600px inbox
pane. In a full-width browser tab that leaves a left-stuck column on a white field.
Force it to sit centered on the site's dark background instead. */
html, body { background-color: #0a0a08 !important; margin: 0; padding: 0; }
table[align="left"] { float: none !important; margin-left: auto !important; margin-right: auto !important; }
</style>
</head>`
const topBar = `
<div style="background:#050505;border-bottom:1px solid rgba(184,151,42,0.25);padding:14px 20px;font-family:Georgia,serif">
<div style="max-width:640px;margin:0 auto;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<a href="${BASE}/" style="color:#e8d9b5;text-decoration:none;font-size:15px;letter-spacing:0.04em">Verse by Verse with Nate</a>
<a href="${BASE}/newsletter" style="color:#B8972A;text-decoration:none;font-size:12px;letter-spacing:0.14em;text-transform:uppercase">&larr; All issues</a>
</div></div>`
const ctaBlock = `
<table border="0" width="100%" cellpadding="0" cellspacing="0" role="presentation" align="center">
<tbody><tr><td align="center" style="padding:40px 20px 56px;background-color:#080807;border-top:1px solid rgba(184,151,42,0.2)">
<table width="580" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width:580px;width:100%">
<tbody><tr><td align="center" style="padding:0;font-family:Georgia,serif">
<p style="margin:0 0 8px;font-size:12px;letter-spacing:0.18em;text-transform:uppercase;color:#B8972A">Stay in the Word</p>
<p style="margin:0 0 22px;font-size:22px;line-height:1.3;color:#f0e9cc">Get the next issue in your inbox</p>
<a href="${BASE}/subscribe" style="display:inline-block;padding:13px 34px;font-size:12px;letter-spacing:0.16em;text-transform:uppercase;color:#0c0c08;background:#B8972A;text-decoration:none;border-radius:4px;font-weight:bold">Subscribe by Email</a>
<p style="margin:26px 0 0;font-size:13px"><a href="${BASE}/newsletter" style="color:#8a7e55;text-decoration:none">&larr; Back to the newsletter archive</a></p>
</td></tr></tbody></table>
</td></tr></tbody></table>
</body>`
let html = stripUnsubscribeLink(issue.html)
html = html.replace(/<title>[\s\S]*?<\/title>/i, '')
html = /<\/head>/i.test(html) ? html.replace(/<\/head>/i, metaBlock) : html.replace(/<head[^>]*>/i, m => `${m}${metaBlock.replace('</head>', '')}`)
html = html.replace(/<body[^>]*>/i, m => `${m}${topBar}`)
html = /<\/body>/i.test(html) ? html.replace(/<\/body>/i, ctaBlock) : `${html}${ctaBlock}`
return html
}
export function register(app) {
// ── Admin: browse Resend broadcasts, flagged by New Episode topic ────────
app.get('/api/admin/newsletter/resend-broadcasts', requireAdminAuth, async (_req, res) => {
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const [{ data: topicsData, error: topicsError }, { data: listData, error: listError }] = await Promise.all([
resendRetry(() => resend.topics.list()),
resendRetry(() => resend.broadcasts.list()),
])
if (topicsError) throw new Error(topicsError.message)
if (listError) throw new Error(listError.message)
const newEpisodeTopic = (topicsData?.data ?? []).find(t => String(t.name || '').trim().toLowerCase() === NEW_EPISODE_TOPIC_NAME)
const sentBroadcasts = (listData?.data ?? []).filter(b => b.status === 'sent')
// Fetch a few at a time so a long broadcast history can't exceed Resend's requests-per-second limit.
const details = []
for (let i = 0; i < sentBroadcasts.length; i += 4) {
const chunk = await Promise.all(sentBroadcasts.slice(i, i + 4).map(b => resendRetry(() => resend.broadcasts.get(b.id))))
details.push(...chunk)
}
const broadcasts = details.map(({ data }, i) => {
const summary = sentBroadcasts[i]
if (!data) return null
return {
id: data.id,
name: data.name,
subject: data.subject,
sentAt: data.sent_at ?? summary.sent_at,
topicId: data.topic_id ?? null,
matchesNewEpisodeTopic: !!newEpisodeTopic && data.topic_id === newEpisodeTopic.id,
alreadyPublished: state.newsletterIssues.some(issue => issue.resendBroadcastId === data.id),
}
}).filter(Boolean).sort((a, b) => new Date(b.sentAt ?? 0).getTime() - new Date(a.sentAt ?? 0).getTime())
res.json({ broadcasts, newEpisodeTopicFound: !!newEpisodeTopic })
} catch (err) {
console.error('[newsletter] failed to list Resend broadcasts:', err)
res.status(502).json({ message: 'Could not reach Resend.' })
}
})
// ── Admin: manage published issues ────────────────────────────────────────
app.get('/api/admin/newsletter/issues', requireAdminAuth, (_req, res) => {
const issues = [...state.newsletterIssues]
.sort((a, b) => new Date(b.sentAt ?? b.publishedAt).getTime() - new Date(a.sentAt ?? a.publishedAt).getTime())
.map(({ html, ...rest }) => rest)
res.json({ issues })
})
app.post('/api/admin/newsletter/issues', requireAdminAuth, async (req, res) => {
const { broadcastId, title: titleOverride, slug: slugOverride, public: isPublic } = req.body ?? {}
if (!broadcastId || typeof broadcastId !== 'string') {
res.status(400).json({ message: 'broadcastId is required.' }); return
}
if (state.newsletterIssues.some(i => i.resendBroadcastId === broadcastId)) {
res.status(409).json({ message: 'This broadcast has already been published.' }); return
}
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const { data, error } = await resend.broadcasts.get(broadcastId)
if (error || !data) throw new Error(error?.message ?? 'Broadcast not found.')
if (!data.html) { res.status(400).json({ message: 'This broadcast has no HTML content.' }); return }
const title = typeof titleOverride === 'string' && titleOverride.trim()
? titleOverride.trim().slice(0, 200)
: stripSubjectPrefix(data.subject) || data.name || 'Newsletter Issue'
const slug = typeof slugOverride === 'string' && slugOverride.trim()
? uniqueSlug(slugOverride)
: uniqueSlug(title)
const issue = {
id: randomUUID(),
resendBroadcastId: data.id,
slug,
title,
subject: data.subject ?? '',
description: extractDescription(data.text, data.preview_text),
html: data.html,
sentAt: data.sent_at ?? null,
publishedAt: new Date().toISOString(),
public: isPublic !== false,
}
state.newsletterIssues.push(issue)
queueNewsletterIssuesWrite()
const { html, ...rest } = issue
res.json({ ok: true, issue: rest })
} catch (err) {
console.error('[newsletter] failed to publish issue:', err)
res.status(502).json({ message: 'Could not fetch broadcast content from Resend.' })
}
})
app.patch('/api/admin/newsletter/issues/:id', requireAdminAuth, (req, res) => {
const issue = state.newsletterIssues.find(i => i.id === req.params.id)
if (!issue) { res.status(404).json({ message: 'Issue not found.' }); return }
if (typeof req.body?.title === 'string' && req.body.title.trim()) issue.title = req.body.title.trim().slice(0, 200)
if (typeof req.body?.slug === 'string' && req.body.slug.trim() && req.body.slug.trim() !== issue.slug) {
const candidate = slugify(req.body.slug)
if (!candidate) { res.status(400).json({ message: 'Invalid slug.' }); return }
if (state.newsletterIssues.some(i => i.id !== issue.id && i.slug === candidate)) {
res.status(409).json({ message: 'That slug is already in use.' }); return
}
issue.slug = candidate
}
if (typeof req.body?.public === 'boolean') issue.public = req.body.public
queueNewsletterIssuesWrite()
const { html, ...rest } = issue
res.json({ ok: true, issue: rest })
})
app.delete('/api/admin/newsletter/issues/:id', requireAdminAuth, (req, res) => {
const idx = state.newsletterIssues.findIndex(i => i.id === req.params.id)
if (idx === -1) { res.status(404).json({ message: 'Issue not found.' }); return }
state.newsletterIssues.splice(idx, 1)
queueNewsletterIssuesWrite()
res.json({ ok: true })
})
// ── Public ─────────────────────────────────────────────────────────────────
app.get('/api/newsletter/issues', (_req, res) => {
const issues = state.newsletterIssues
.filter(i => i.public !== false)
.sort((a, b) => new Date(b.sentAt ?? b.publishedAt).getTime() - new Date(a.sentAt ?? a.publishedAt).getTime())
.map(({ html, ...rest }) => rest)
res.json({ issues })
})
app.get('/newsletter/:slug', (req, res, next) => {
const issue = state.newsletterIssues.find(i => i.slug === req.params.slug && i.public !== false)
if (!issue) { next(); return }
res.type('html').send(buildIssuePageHtml(issue))
})
}
+670
View File
@@ -0,0 +1,670 @@
import { randomUUID } from 'node:crypto'
import { readFile, unlink, mkdir } from 'node:fs/promises'
import { writeFile } from '../atomic-write.js'
import path from 'node:path'
import multer from 'multer'
import rateLimit from 'express-rate-limit'
import { createHash, timingSafeEqual } from 'node:crypto'
import { parseCookies, cookieSecureFlag } from '../helpers.js'
import { requireAdminAuth } from '../auth.js'
import {
sendNuggetsUploadNotification,
sendNuggetsHostWelcomeEmail,
sendNuggetsHostOtpEmail,
getCanonicalBaseUrl,
} from '../email.js'
import { markSeafileFileSeen } from '../nuggets-seafile-watcher.js'
import {
NUGGETS_UPLOAD_HOSTS_FILE,
NUGGETS_UPLOAD_HISTORY_FILE,
NUGGETS_PORTAL_ANNOUNCEMENT_FILE,
NUGGETS_PORTAL_GUIDELINES_FILE,
NUGGETS_NOTIFY_EMAIL,
SEAFILE_URL,
SEAFILE_TOKEN,
SEAFILE_NUGGETS_REPO_ID,
SEAFILE_EPISODE_INFO_DIR,
SEAFILE_EPISODE_UPLOAD_DIR,
SEAFILE_NUGGETS_SHARE_URL,
HOST_SESSION_COOKIE,
HOST_SESSION_TTL_MS,
DATA_DIR,
} from '../config.js'
// ── In-memory host sessions ───────────────────────────────────────────────
const hostSessions = new Map() // token → { hostId, hostName, expiresAt }
function createHostSession(hostId, hostName) {
const token = randomUUID()
hostSessions.set(token, { hostId, hostName, expiresAt: Date.now() + HOST_SESSION_TTL_MS })
return token
}
function getHostSession(req) {
const cookies = parseCookies(req.headers.cookie)
const token = cookies[HOST_SESSION_COOKIE]
if (!token) return null
const session = hostSessions.get(token)
if (!session || session.expiresAt <= Date.now()) {
hostSessions.delete(token)
return null
}
return session
}
function setHostSessionCookie(res, token) {
res.append(
'Set-Cookie',
`${HOST_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(HOST_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
)
}
function clearHostSessionCookie(res) {
res.append(
'Set-Cookie',
`${HOST_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
)
}
function requireHostAuth(req, res, next) {
if (!getHostSession(req)) {
res.status(401).json({ message: 'Not authenticated.' })
return
}
next()
}
// ── In-memory pending OTP store ───────────────────────────────────────────
const OTP_TTL_MS = 10 * 60 * 1000 // 10 minutes
const pendingOtpStore = new Map() // otpToken → { hostId, hostName, code, expiresAt, attempts }
function generateOtpCode() {
// 6-digit numeric code
return String(Math.floor(100000 + Math.random() * 900000))
}
// ── Host credentials data ─────────────────────────────────────────────────
function hashPassword(pw) {
return createHash('sha256').update(String(pw)).digest('hex')
}
function passwordsMatch(plain, hash) {
const a = Buffer.from(hashPassword(plain), 'utf8')
const b = Buffer.from(hash, 'utf8')
if (a.length !== b.length) return false
return timingSafeEqual(a, b)
}
async function loadUploadHosts() {
try {
const raw = await readFile(NUGGETS_UPLOAD_HOSTS_FILE, 'utf8')
return JSON.parse(raw)
} catch {
return { hosts: [] }
}
}
async function saveUploadHosts(data) {
await writeFile(NUGGETS_UPLOAD_HOSTS_FILE, JSON.stringify(data, null, 2), 'utf8')
}
// ── History helpers ───────────────────────────────────────────────────────
export async function loadHistory() {
try {
const raw = await readFile(NUGGETS_UPLOAD_HISTORY_FILE, 'utf8')
return JSON.parse(raw)
} catch {
return { entries: [] }
}
}
async function appendHistory(entry) {
const data = await loadHistory()
data.entries.unshift({ ...entry, uploadedAt: new Date().toISOString() })
// Keep last 1000 entries
if (data.entries.length > 1000) data.entries = data.entries.slice(0, 1000)
await writeFile(NUGGETS_UPLOAD_HISTORY_FILE, JSON.stringify(data, null, 2), 'utf8')
}
// ── Announcement helpers ──────────────────────────────────────────────────
async function loadAnnouncement() {
try {
const raw = await readFile(NUGGETS_PORTAL_ANNOUNCEMENT_FILE, 'utf8')
return JSON.parse(raw)
} catch {
return { text: '', updatedAt: null }
}
}
async function saveAnnouncement(text) {
const data = { text: String(text ?? '').trim(), updatedAt: new Date().toISOString() }
await writeFile(NUGGETS_PORTAL_ANNOUNCEMENT_FILE, JSON.stringify(data, null, 2), 'utf8')
return data
}
// ── Guidelines helpers ────────────────────────────────────────────────────
const DEFAULT_GUIDELINES = [
'Export as MP3 or WAV at 44.1 kHz, 128 kbps minimum.',
'File name: YYYY-MM-DD-EpisodeName.mp3 (e.g. 2026-09-09-Romans-5.mp3).',
'Recommended length: 8–15 minutes. Keep a 0.5s lead-in and 1s fade-out.',
'Normalize to -1 dBFS before exporting.',
'Match the episode name here to the file name exactly so search stays clean.',
]
async function loadGuidelines() {
try {
const raw = await readFile(NUGGETS_PORTAL_GUIDELINES_FILE, 'utf8')
return JSON.parse(raw)
} catch {
return { lines: DEFAULT_GUIDELINES, updatedAt: null }
}
}
async function saveGuidelines(lines) {
const data = { lines, updatedAt: new Date().toISOString() }
await writeFile(NUGGETS_PORTAL_GUIDELINES_FILE, JSON.stringify(data, null, 2), 'utf8')
return data
}
// ── Multer (disk storage, temp dir) ──────────────────────────────────────
const UPLOAD_TEMP_DIR = path.join(DATA_DIR, 'upload-tmp')
const upload = multer({
dest: UPLOAD_TEMP_DIR,
limits: { fileSize: 500 * 1024 * 1024 }, // 500 MB
})
// ── Seafile helpers ───────────────────────────────────────────────────────
function seafileConfigured() {
return Boolean(SEAFILE_URL && SEAFILE_TOKEN && SEAFILE_NUGGETS_REPO_ID)
}
async function getSeafileUploadLink(dir) {
const url = `${SEAFILE_URL}/api2/repos/${SEAFILE_NUGGETS_REPO_ID}/upload-link/?p=${encodeURIComponent(dir)}`
const res = await fetch(url, {
headers: { Authorization: `Token ${SEAFILE_TOKEN}` },
})
if (!res.ok) {
const body = await res.text()
throw new Error(`Seafile upload-link failed (${res.status}): ${body}`)
}
// Seafile returns a quoted string: "https://..."
const text = await res.text()
return text.replace(/^"|"$/g, '')
}
async function uploadToSeafile(uploadLink, fileBuffer, filename, parentDir) {
const formData = new FormData()
formData.append('file', new Blob([fileBuffer]), filename)
formData.append('parent_dir', parentDir)
formData.append('filename', filename)
formData.append('replace', '1')
const res = await fetch(uploadLink, {
method: 'POST',
headers: { Authorization: `Token ${SEAFILE_TOKEN}` },
body: formData,
})
if (!res.ok) {
const body = await res.text()
throw new Error(`Seafile upload failed (${res.status}): ${body}`)
}
await res.text()
}
// ── Rate limiters ─────────────────────────────────────────────────────────
const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many login attempts. Wait 15 minutes and try again.' },
skipSuccessfulRequests: true,
})
const otpRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many OTP attempts. Wait 15 minutes and try again.' },
})
// ── Route registration ────────────────────────────────────────────────────
export function register(app) {
// Ensure temp upload dir exists (non-blocking; created well before any upload)
mkdir(UPLOAD_TEMP_DIR, { recursive: true }).catch(err => {
console.error('[nuggets-upload] could not create upload-tmp dir:', err.message)
})
// ── Host auth ─────────────────────────────────────────────────────────
app.get('/api/nuggets-upload/me', async (req, res) => {
const session = getHostSession(req)
if (!session) {
res.json({ authenticated: false })
return
}
const data = await loadUploadHosts()
const host = data.hosts.find(h => h.id === session.hostId)
res.json({
authenticated: true,
hostName: session.hostName,
configured: seafileConfigured(),
twoFaEnabled: Boolean(host?.twoFa),
})
})
app.post('/api/nuggets-upload/login', loginRateLimiter, async (req, res) => {
const email = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''
const password = typeof req.body?.password === 'string' ? req.body.password : ''
if (!email || !password) {
res.status(400).json({ message: 'Email and password are required.' })
return
}
const data = await loadUploadHosts()
const host = data.hosts.find(h => h.email === email && h.active)
if (!host || !passwordsMatch(password, host.passwordHash)) {
res.status(401).json({ message: 'Invalid email or password.' })
return
}
// Check if 2FA is enabled for this host
if (host.twoFa) {
const code = generateOtpCode()
const otpToken = randomUUID()
pendingOtpStore.set(otpToken, {
hostId: host.id,
hostName: host.name,
code,
expiresAt: Date.now() + OTP_TTL_MS,
attempts: 0,
})
sendNuggetsHostOtpEmail({ to: host.email, name: host.name, code }).catch(
err => console.error('[nuggets-otp] send error:', err),
)
res.json({ requiresOtp: true, otpToken })
return
}
const token = createHostSession(host.id, host.name)
setHostSessionCookie(res, token)
res.json({ ok: true, hostName: host.name })
})
app.post('/api/nuggets-upload/verify-otp', otpRateLimiter, (req, res) => {
const otpToken = typeof req.body?.otpToken === 'string' ? req.body.otpToken : ''
const code = typeof req.body?.code === 'string' ? req.body.code.trim() : ''
if (!otpToken || !code) {
res.status(400).json({ message: 'OTP token and code are required.' })
return
}
const pending = pendingOtpStore.get(otpToken)
if (!pending || pending.expiresAt <= Date.now()) {
pendingOtpStore.delete(otpToken)
res.status(401).json({ message: 'Code expired. Please sign in again.' })
return
}
pending.attempts += 1
if (pending.attempts > 5) {
pendingOtpStore.delete(otpToken)
res.status(429).json({ message: 'Too many attempts. Please sign in again.' })
return
}
if (pending.code !== code) {
res.status(401).json({ message: 'Incorrect code. Try again.' })
return
}
pendingOtpStore.delete(otpToken)
const sessionToken = createHostSession(pending.hostId, pending.hostName)
setHostSessionCookie(res, sessionToken)
res.json({ ok: true, hostName: pending.hostName })
})
app.post('/api/nuggets-upload/logout', (req, res) => {
const cookies = parseCookies(req.headers.cookie)
const token = cookies[HOST_SESSION_COOKIE]
if (token) hostSessions.delete(token)
clearHostSessionCookie(res)
res.json({ ok: true })
})
app.post('/api/nuggets-upload/change-password', requireHostAuth, async (req, res) => {
const session = getHostSession(req)
const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : ''
const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : ''
if (!currentPassword || !newPassword) {
res.status(400).json({ message: 'Current and new password are required.' })
return
}
if (newPassword.length < 8) {
res.status(400).json({ message: 'New password must be at least 8 characters.' })
return
}
const data = await loadUploadHosts()
const idx = data.hosts.findIndex(h => h.id === session.hostId)
if (idx === -1) {
res.status(404).json({ message: 'Host account not found.' })
return
}
if (!passwordsMatch(currentPassword, data.hosts[idx].passwordHash)) {
res.status(401).json({ message: 'Current password is incorrect.' })
return
}
data.hosts[idx].passwordHash = hashPassword(newPassword)
await saveUploadHosts(data)
res.json({ ok: true })
})
app.post('/api/nuggets-upload/2fa/enable', requireHostAuth, async (req, res) => {
const session = getHostSession(req)
const data = await loadUploadHosts()
const idx = data.hosts.findIndex(h => h.id === session.hostId)
if (idx === -1) {
res.status(404).json({ message: 'Host account not found.' })
return
}
data.hosts[idx].twoFa = true
await saveUploadHosts(data)
res.json({ ok: true })
})
app.post('/api/nuggets-upload/2fa/disable', requireHostAuth, async (req, res) => {
const session = getHostSession(req)
const data = await loadUploadHosts()
const idx = data.hosts.findIndex(h => h.id === session.hostId)
if (idx === -1) {
res.status(404).json({ message: 'Host account not found.' })
return
}
data.hosts[idx].twoFa = false
await saveUploadHosts(data)
res.json({ ok: true })
})
// ── Episode upload (audio file + metadata) ───────────────────────────
app.post('/api/nuggets-upload/upload', requireHostAuth, (req, res, next) => {
upload.single('file')(req, res, err => {
if (!err) return next()
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ message: 'File exceeds the 500 MB limit.' })
}
console.error('[nuggets-upload] multer error:', err.message)
return res.status(500).json({ message: `File upload error: ${err.message}` })
})
}, async (req, res) => {
if (!req.file) {
res.status(400).json({ message: 'No file received.' })
return
}
const session = getHostSession(req)
const episodeName = typeof req.body?.episodeName === 'string' ? req.body.episodeName.trim() : ''
const passage = typeof req.body?.passage === 'string' ? req.body.passage.trim() : ''
const description = typeof req.body?.description === 'string' ? req.body.description.trim() : ''
const originalName = req.file.originalname || req.file.filename
const tempPath = req.file.path
if (!episodeName || !passage) {
await unlink(tempPath).catch(() => {})
res.status(400).json({ message: 'Episode name and passage are required.' })
return
}
if (!seafileConfigured()) {
await unlink(tempPath).catch(() => {})
res.status(503).json({ message: 'Seafile is not configured on this server. Contact the admin.' })
return
}
try {
const fileBuffer = await readFile(tempPath)
// Upload audio to /Episode Upload/
const audioLink = await getSeafileUploadLink(SEAFILE_EPISODE_UPLOAD_DIR)
await uploadToSeafile(audioLink, fileBuffer, originalName, SEAFILE_EPISODE_UPLOAD_DIR)
await unlink(tempPath).catch(() => {})
// Build and upload info file to /Episode Info/
const now = new Date()
const dateStr = now.toISOString().slice(0, 10)
const infoContent = [
`Episode: ${episodeName}`,
`Host: ${session?.hostName ?? 'Unknown'}`,
`Passage: ${passage}`,
`Audio File: ${originalName}`,
`Submitted: ${now.toUTCString()}`,
'',
'Description:',
description || '(none)',
].join('\n')
const safeEpName = episodeName.replace(/[^a-z0-9 _-]/gi, '').trim().replace(/\s+/g, '-').slice(0, 60)
const infoFilename = `${dateStr}-${safeEpName}-info.txt`
const infoBuffer = Buffer.from(infoContent, 'utf8')
const infoLink = await getSeafileUploadLink(SEAFILE_EPISODE_INFO_DIR)
await uploadToSeafile(infoLink, infoBuffer, infoFilename, SEAFILE_EPISODE_INFO_DIR)
console.log(`[nuggets-upload] ${session?.hostName ?? 'unknown'} uploaded "${episodeName}" (${originalName})`)
// Mark audio file as seen so the Seafile watcher doesn't double-notify
await markSeafileFileSeen(originalName)
// Persist to history
await appendHistory({
hostId: session?.hostId ?? '',
hostName: session?.hostName ?? '',
episodeName,
passage,
description,
filename: originalName,
infoFile: infoFilename,
})
// Send admin notification (fire-and-forget)
sendNuggetsUploadNotification({
to: NUGGETS_NOTIFY_EMAIL,
source: 'portal',
hostName: session?.hostName ?? '',
episodeName,
passage,
description,
filename: originalName,
infoFile: infoFilename,
}).catch(err => console.error('[nuggets-upload] notify error:', err))
res.json({
ok: true,
episodeName,
filename: originalName,
infoFile: infoFilename,
seafileUrl: SEAFILE_NUGGETS_SHARE_URL,
})
} catch (err) {
await unlink(tempPath).catch(() => {})
console.error('[nuggets-upload] upload error:', err.message)
res.status(500).json({ message: `Upload failed: ${err.message}` })
}
})
// ── Host: episode history ─────────────────────────────────────────────
app.get('/api/nuggets-upload/history', requireHostAuth, async (req, res) => {
const session = getHostSession(req)
const data = await loadHistory()
const entries = data.entries.filter(e => e.hostId === session.hostId)
res.json({ entries })
})
// ── Public: announcement ──────────────────────────────────────────────
app.get('/api/nuggets-upload/announcement', async (_req, res) => {
const data = await loadAnnouncement()
res.json(data)
})
// ── Public: recording guidelines ─────────────────────────────────────
app.get('/api/nuggets-upload/guidelines', async (_req, res) => {
const data = await loadGuidelines()
res.json(data)
})
// ── Admin: manage upload host accounts ───────────────────────────────
app.get('/api/admin/nuggets-upload/hosts', requireAdminAuth, async (_req, res) => {
const data = await loadUploadHosts()
// Strip password hashes from the response
const hosts = data.hosts.map(({ passwordHash: _ph, ...h }) => h)
res.json({ hosts })
})
app.post('/api/admin/nuggets-upload/hosts', requireAdminAuth, async (req, res) => {
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''
const email = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''
const password = typeof req.body?.password === 'string' ? req.body.password : ''
if (!name || !email || !password) {
res.status(400).json({ message: 'Name, email, and password are required.' })
return
}
if (password.length < 8) {
res.status(400).json({ message: 'Password must be at least 8 characters.' })
return
}
const data = await loadUploadHosts()
if (data.hosts.some(h => h.email === email)) {
res.status(409).json({ message: 'An account with that email already exists.' })
return
}
const host = {
id: randomUUID(),
name,
email,
passwordHash: hashPassword(password),
active: true,
twoFa: false,
createdAt: new Date().toISOString(),
}
data.hosts.push(host)
await saveUploadHosts(data)
sendNuggetsHostWelcomeEmail({
to: email,
name,
username: email,
password,
portalUrl: `${getCanonicalBaseUrl().replace(/\/$/, '')}/host`,
}).catch(err => console.error('[nuggets-host] welcome email error:', err))
const { passwordHash: _ph, ...safeHost } = host
res.json({ ok: true, host: safeHost, emailSent: true })
})
app.put('/api/admin/nuggets-upload/hosts/:id', requireAdminAuth, async (req, res) => {
const { id } = req.params
const data = await loadUploadHosts()
const idx = data.hosts.findIndex(h => h.id === id)
if (idx === -1) {
res.status(404).json({ message: 'Host not found.' })
return
}
const patch = {}
if (typeof req.body?.name === 'string') patch.name = req.body.name.trim()
if (typeof req.body?.active === 'boolean') patch.active = req.body.active
if (typeof req.body?.password === 'string' && req.body.password.length > 0) {
if (req.body.password.length < 8) {
res.status(400).json({ message: 'Password must be at least 8 characters.' })
return
}
patch.passwordHash = hashPassword(req.body.password)
}
const newPassword = typeof req.body?.password === 'string' && req.body.password.length > 0 ? req.body.password : null
data.hosts[idx] = { ...data.hosts[idx], ...patch }
await saveUploadHosts(data)
// Email the host their new password if they have an address on file
if (newPassword && data.hosts[idx].email) {
sendNuggetsHostWelcomeEmail({
to: data.hosts[idx].email,
name: data.hosts[idx].name,
username: data.hosts[idx].email,
password: newPassword,
portalUrl: `${getCanonicalBaseUrl().replace(/\/$/, '')}/host`,
}).catch(err => console.error('[nuggets-host] reset email error:', err))
}
const { passwordHash: _ph, ...safeHost } = data.hosts[idx]
res.json({ ok: true, host: safeHost, emailSent: Boolean(newPassword && data.hosts[idx].email) })
})
app.delete('/api/admin/nuggets-upload/hosts/:id', requireAdminAuth, async (req, res) => {
const { id } = req.params
const data = await loadUploadHosts()
const idx = data.hosts.findIndex(h => h.id === id)
if (idx === -1) {
res.status(404).json({ message: 'Host not found.' })
return
}
data.hosts.splice(idx, 1)
await saveUploadHosts(data)
res.json({ ok: true })
})
// ── Admin: announcement ───────────────────────────────────────────────
app.post('/api/admin/nuggets-upload/announcement', requireAdminAuth, async (req, res) => {
const text = typeof req.body?.text === 'string' ? req.body.text : ''
if (text.length > 500) {
res.status(400).json({ message: 'Announcement must be 500 characters or fewer.' })
return
}
const data = await saveAnnouncement(text)
res.json({ ok: true, ...data })
})
app.delete('/api/admin/nuggets-upload/announcement', requireAdminAuth, async (_req, res) => {
const data = await saveAnnouncement('')
res.json({ ok: true, ...data })
})
// ── Admin: recording guidelines ───────────────────────────────────────
app.post('/api/admin/nuggets-upload/guidelines', requireAdminAuth, async (req, res) => {
const raw = typeof req.body?.lines === 'string' ? req.body.lines : ''
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean)
if (lines.length > 20) {
res.status(400).json({ message: 'Maximum 20 guideline lines.' })
return
}
const data = await saveGuidelines(lines)
res.json({ ok: true, ...data })
})
app.delete('/api/admin/nuggets-upload/guidelines', requireAdminAuth, async (_req, res) => {
const data = await saveGuidelines(DEFAULT_GUIDELINES)
res.json({ ok: true, ...data })
})
// ── Admin: upload history ─────────────────────────────────────────────
app.get('/api/admin/nuggets-upload/history', requireAdminAuth, async (_req, res) => {
const data = await loadHistory()
res.json({ entries: data.entries })
})
}
+156
View File
@@ -0,0 +1,156 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { Readable } from 'node:stream'
import path from 'node:path'
import { writeFile } from '../atomic-write.js'
import { requireAdminAuth } from '../auth.js'
import { loadHistory } from './nuggets-host-upload.js'
import {
DATA_DIR,
SEAFILE_URL,
SEAFILE_TOKEN,
SEAFILE_NUGGETS_REPO_ID,
SEAFILE_EPISODE_UPLOAD_DIR,
} from '../config.js'
const WORKFLOW_FILE = path.join(DATA_DIR, 'nuggets-workflow.json')
const AUDIO_TYPES = {
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.m4a': 'audio/mp4',
'.aac': 'audio/aac',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
}
export const WORKFLOW_STEPS = ['listen', 'coldOpen', 'recordColdOpen', 'edit', 'spotify']
export async function loadWorkflow() {
try {
return JSON.parse(await readFile(WORKFLOW_FILE, 'utf8'))
} catch {
return { items: {} }
}
}
export function entryId(entry) {
return createHash('sha1').update(`${entry.uploadedAt}|${entry.filename}`).digest('hex').slice(0, 16)
}
function emptyState() {
return {
steps: Object.fromEntries(WORKFLOW_STEPS.map(s => [s, null])), // step → ISO completion time
coldOpenText: '',
notes: '',
spotifyUrl: '',
}
}
function seafileConfigured() {
return Boolean(SEAFILE_URL && SEAFILE_TOKEN && SEAFILE_NUGGETS_REPO_ID)
}
// Portal uploads whose workflow still has steps left to do (any age, so an old one keeps showing).
export async function countPendingUploads() {
const [history, wf] = await Promise.all([loadHistory(), loadWorkflow()])
return history.entries.filter(entry => {
const steps = wf.items[entryId(entry)]?.steps ?? {}
return !WORKFLOW_STEPS.every(step => steps[step])
}).length
}
export function register(app) {
app.get('/api/admin/nuggets-workflow', requireAdminAuth, async (_req, res) => {
const [history, wf] = await Promise.all([loadHistory(), loadWorkflow()])
const items = history.entries.map(entry => {
const id = entryId(entry)
return { id, entry, state: { ...emptyState(), ...(wf.items[id] ?? {}) } }
})
res.json({ items })
})
app.put('/api/admin/nuggets-workflow/:id', requireAdminAuth, async (req, res) => {
const { id } = req.params
const history = await loadHistory()
if (!history.entries.some(e => entryId(e) === id)) {
res.status(404).json({ message: 'Upload not found.' })
return
}
const wf = await loadWorkflow()
const prev = { ...emptyState(), ...(wf.items[id] ?? {}) }
const body = req.body ?? {}
const next = { ...prev, steps: { ...prev.steps } }
if (body.steps && typeof body.steps === 'object') {
for (const s of WORKFLOW_STEPS) {
if (typeof body.steps[s] === 'boolean') {
// Keep the original completion time if the step was already done
next.steps[s] = body.steps[s] ? (prev.steps[s] ?? new Date().toISOString()) : null
}
}
}
if (typeof body.coldOpenText === 'string') next.coldOpenText = body.coldOpenText.slice(0, 5000)
if (typeof body.notes === 'string') next.notes = body.notes.slice(0, 5000)
if (typeof body.spotifyUrl === 'string') next.spotifyUrl = body.spotifyUrl.trim().slice(0, 500)
wf.items[id] = next
await writeFile(WORKFLOW_FILE, JSON.stringify(wf, null, 2), 'utf8')
res.json({ ok: true, state: next })
})
// Stream the uploaded audio from Seafile so it can be auditioned in the admin.
app.get('/api/admin/nuggets-workflow/:id/audio', requireAdminAuth, async (req, res) => {
if (!seafileConfigured()) {
res.status(503).json({ message: 'Seafile is not configured.' })
return
}
const history = await loadHistory()
const entry = history.entries.find(e => entryId(e) === req.params.id)
if (!entry) {
res.status(404).json({ message: 'Upload not found.' })
return
}
try {
const filePath = `${SEAFILE_EPISODE_UPLOAD_DIR.replace(/\/$/, '')}/${entry.filename}`
const linkRes = await fetch(
`${SEAFILE_URL}/api2/repos/${SEAFILE_NUGGETS_REPO_ID}/file/?p=${encodeURIComponent(filePath)}&reuse=1`,
{ headers: { Authorization: `Token ${SEAFILE_TOKEN}` }, signal: AbortSignal.timeout(10_000) },
)
if (!linkRes.ok) {
console.error(`[nuggets-workflow] Seafile link lookup for "${filePath}" failed: ${linkRes.status}`)
res.status(404).json({ message: 'Audio file not found in Seafile.' })
return
}
const downloadUrl = (await linkRes.text()).replace(/^"|"$/g, '')
const headers = {}
if (req.headers.range) headers.Range = req.headers.range
const upstream = await fetch(downloadUrl, { headers })
if (!upstream.ok && upstream.status !== 206) {
console.error(`[nuggets-workflow] Seafile download failed: ${upstream.status}`)
res.status(502).json({ message: 'Could not fetch audio from Seafile.' })
return
}
res.status(upstream.status)
for (const h of ['content-length', 'content-range', 'accept-ranges']) {
const v = upstream.headers.get(h)
if (v) res.setHeader(h, v)
}
// Seafile serves downloads as application/octet-stream, which browsers refuse to play
// (the site sends nosniff), so set the audio type from the file extension.
const ext = path.extname(entry.filename).toLowerCase()
res.setHeader('content-type', AUDIO_TYPES[ext] ?? 'audio/mpeg')
if (!res.getHeader('accept-ranges')) res.setHeader('accept-ranges', 'bytes')
const stream = Readable.fromWeb(upstream.body)
req.on('close', () => stream.destroy())
stream.on('error', () => res.end())
stream.pipe(res)
} catch (err) {
console.error('[nuggets-workflow] audio error:', err.message, err.cause?.message ?? '')
// Admin-only route, so the reason is safe to show and saves a trip to the server logs.
const reason = [err.message, err.cause?.message].filter(Boolean).join(': ')
if (!res.headersSent) res.status(500).json({ message: `Audio stream failed${reason ? ` (${reason})` : ''}.` })
}
})
}
+99
View File
@@ -0,0 +1,99 @@
import { parseRssItems } from '../rss-helpers.js'
import { state } from '../state.js'
import { requireAdminAuth } from '../auth.js'
import { queueNuggetsTagsWrite } from '../data.js'
let cache = null // { url, episodes }
let cacheAt = 0
const CACHE_TTL = 30 * 60 * 1000
function feedUrl() {
return state.cachedSiteContent?.nuggets?.rssFeedUrl?.trim() ?? ''
}
export async function fetchNuggetsEpisodes() {
const url = feedUrl()
if (!url) { cache = null; return [] }
const now = Date.now()
if (cache && cache.url === url && (now - cacheAt) < CACHE_TTL) {
return cache.episodes
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 8000)
try {
const response = await fetch(url, { signal: controller.signal })
clearTimeout(timeout)
if (!response.ok) throw new Error(`RSS fetch failed: ${response.status}`)
const xml = await response.text()
const episodes = parseRssItems(xml)
cache = { url, episodes }
cacheAt = now
return episodes
} catch (err) {
clearTimeout(timeout)
// Serve stale cache for this same URL rather than a hard failure, if we have one.
if (cache && cache.url === url) return cache.episodes
throw err
}
}
function withTags(episodes) {
return episodes.map(ep => ({ ...ep, tags: state.nuggetsEpisodeTags[ep.audioUrl] ?? [] }))
}
function cleanTags(input) {
const list = Array.isArray(input) ? input : []
const seen = new Set()
const out = []
for (const t of list) {
const tag = String(t ?? '').trim().replace(/\s+/g, ' ').slice(0, 40)
const key = tag.toLowerCase()
if (tag && !seen.has(key)) { seen.add(key); out.push(tag) }
}
return out.slice(0, 12)
}
export function register(app) {
app.get('/api/admin/nuggets/episode-tags', requireAdminAuth, async (_req, res) => {
try {
const episodes = await fetchNuggetsEpisodes()
res.json({ episodes: withTags(episodes).map(({ title, pubDate, audioUrl, episode, tags }) => ({ title, pubDate, audioUrl, episode, tags })) })
} catch (err) {
res.status(502).json({ message: 'Could not load episodes from the feed.' })
}
})
app.put('/api/admin/nuggets/episode-tags', requireAdminAuth, (req, res) => {
const audioUrl = String(req.body?.audioUrl ?? '')
if (!audioUrl) { res.status(400).json({ message: 'audioUrl is required.' }); return }
const tags = cleanTags(req.body?.tags)
if (tags.length) state.nuggetsEpisodeTags[audioUrl] = tags
else delete state.nuggetsEpisodeTags[audioUrl]
queueNuggetsTagsWrite()
res.json({ ok: true, tags })
})
app.get('/api/nuggets/episodes', async (_req, res) => {
const configured = Boolean(feedUrl())
try {
const episodes = await fetchNuggetsEpisodes()
res.json({ episodes: withTags(episodes.slice(0, 6)), configured })
} catch (err) {
console.error('[nuggets/episodes] RSS fetch error:', err.message)
res.json({ episodes: [], configured })
}
})
app.get('/api/nuggets/episodes/all', async (_req, res) => {
const configured = Boolean(feedUrl())
try {
const episodes = await fetchNuggetsEpisodes()
res.json({ episodes: withTags(episodes), configured })
} catch (err) {
console.error('[nuggets/episodes/all] RSS fetch error:', err.message)
res.json({ episodes: [], configured })
}
})
}
+188
View File
@@ -0,0 +1,188 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { writeFile } from '../atomic-write.js'
import { requireAdminAuth } from '../auth.js'
import { state } from '../state.js'
import { DATA_DIR } from '../config.js'
import { scriptKey } from '../episode-script-keys.js'
import { fetchAllEpisodes } from './episodes.js'
const WORKFLOW_FILE = path.join(DATA_DIR, 'podcast-workflow.json')
const STEPS = ['readScript', 'record', 'edit', 'upload']
export async function loadPodcastWorkflow() {
try {
const data = JSON.parse(await readFile(WORKFLOW_FILE, 'utf8'))
return { planned: Array.isArray(data.planned) ? data.planned : [], items: data.items ?? {}, laneOverrides: data.laneOverrides ?? {}, laneSeasons: data.laneSeasons ?? {} }
} catch {
return { planned: [], items: {}, laneOverrides: {}, laneSeasons: {} }
}
}
const save = data => writeFile(WORKFLOW_FILE, JSON.stringify(data, null, 2), 'utf8')
function emptyState() {
return {
steps: Object.fromEntries(STEPS.map(s => [s, null])),
notes: '',
}
}
// A lane's season comes from the workflow page's own setting, falling back to Current Series.
export function laneSeasonOf(lane, wf, content = state.cachedSiteContent ?? {}) {
const saved = wf.laneSeasons?.[lane]
if (/^\d+$/.test(String(saved ?? ''))) return String(saved)
const fallback = lane === 'A' ? content.seriesSeason : content.secondSeries?.season
return fallback ? String(fallback) : ''
}
function laneFor(season, wf) {
const s = String(season ?? '')
if (s && s === laneSeasonOf('A', wf)) return 'A'
if (s && s === laneSeasonOf('B', wf)) return 'B'
return 'other'
}
const normTitle = t => String(t ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
const plannedKey = p => scriptKey(p.season, p.episodeNumber)
function toIsoDate(pubDate) {
const d = new Date(pubDate)
return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10)
}
export function register(app) {
app.get('/api/admin/podcast-workflow', requireAdminAuth, async (_req, res) => {
const wf = await loadPodcastWorkflow()
let feed = []
let feedOk = true
try {
feed = await fetchAllEpisodes()
} catch {
feedOk = false
}
const byKey = new Map()
const upsert = (key, patch) => byKey.set(key, { ...(byKey.get(key) ?? {}), key, ...patch })
for (const p of wf.planned) {
const key = plannedKey(p)
const prev = byKey.get(key)
upsert(key, { season: p.season, episodeNumber: p.episodeNumber, title: prev?.title || p.title || '', planned: true })
}
for (const item of feed) {
const hasNum = /^\d+$/.test(item.episode)
let key
if (item.season && hasNum) {
key = scriptKey(item.season, item.episode)
} else {
// No season in the feed: pair with a script/planned episode of the same title, else keep it on its own.
const t = normTitle(item.title)
const match = [...byKey.values()].find(e => !e.released && normTitle(e.title) === t)
key = match ? match.key : `t:${t}`
}
const prev = byKey.get(key)
upsert(key, {
season: item.season || prev?.season || '',
episodeNumber: hasNum ? item.episode : (prev?.episodeNumber ?? ''),
title: item.title || prev?.title || '',
released: true,
releaseDate: toIsoDate(item.pubDate),
spotifyUrl: item.link || '',
})
}
const items = [...byKey.values()].map(e => ({
...e,
lane: wf.laneOverrides[e.key] ?? laneFor(e.season, wf),
laneOverride: wf.laneOverrides[e.key] ?? '',
released: Boolean(e.released),
state: { ...emptyState(), ...(wf.items[e.key] ?? {}) },
}))
items.sort((a, b) => (Number(a.season) || 0) - (Number(b.season) || 0) || (Number(a.episodeNumber) || 0) - (Number(b.episodeNumber) || 0))
res.json({ items, feedOk, laneSeasons: { A: laneSeasonOf('A', wf), B: laneSeasonOf('B', wf) } })
})
// Start the workflow for a new episode. Name, season and episode number are all required
// so the episode lands in the right lane and matches the feed once it is released.
app.post('/api/admin/podcast-workflow/planned', requireAdminAuth, async (req, res) => {
const season = String(req.body?.season ?? '').trim()
const episodeNumber = String(req.body?.episodeNumber ?? '').trim()
const title = typeof req.body?.title === 'string' ? req.body.title.trim().slice(0, 160) : ''
if (!title) {
res.status(400).json({ message: 'Episode name is required.' })
return
}
if (!/^\d+$/.test(season) || !/^\d+$/.test(episodeNumber)) {
res.status(400).json({ message: 'Season and episode number must be whole numbers.' })
return
}
const wf = await loadPodcastWorkflow()
const key = scriptKey(season, episodeNumber)
if (wf.planned.some(p => plannedKey(p) === key)) {
res.status(409).json({ message: `Season ${season} episode ${episodeNumber} has already been started.` })
return
}
wf.planned.push({ season, episodeNumber, title })
await save(wf)
res.json({ ok: true, key })
})
app.delete('/api/admin/podcast-workflow/planned/:key', requireAdminAuth, async (req, res) => {
const wf = await loadPodcastWorkflow()
wf.planned = wf.planned.filter(p => plannedKey(p) !== req.params.key)
delete wf.items[req.params.key]
delete wf.laneOverrides[req.params.key]
await save(wf)
res.json({ ok: true })
})
// Set the season number each lane looks for ('' goes back to the Current Series value).
app.put('/api/admin/podcast-workflow-lane-seasons', requireAdminAuth, async (req, res) => {
const wf = await loadPodcastWorkflow()
for (const lane of ['A', 'B']) {
const v = req.body?.[lane]
if (v === undefined) continue
const str = String(v).trim()
if (str === '') delete wf.laneSeasons[lane]
else if (/^\d+$/.test(str)) wf.laneSeasons[lane] = str
else { res.status(400).json({ message: 'Season must be a whole number.' }); return }
}
await save(wf)
res.json({ ok: true })
})
// Assign an episode to a lane by hand ('auto' clears the override and goes back to season matching).
app.put('/api/admin/podcast-workflow/:key/lane', requireAdminAuth, async (req, res) => {
const lane = req.body?.lane
if (!['A', 'B', 'other', 'auto'].includes(lane)) {
res.status(400).json({ message: 'Lane must be A, B, other or auto.' })
return
}
const wf = await loadPodcastWorkflow()
if (lane === 'auto') delete wf.laneOverrides[req.params.key]
else wf.laneOverrides[req.params.key] = lane
await save(wf)
res.json({ ok: true })
})
app.put('/api/admin/podcast-workflow/:key', requireAdminAuth, async (req, res) => {
const { key } = req.params
const wf = await loadPodcastWorkflow()
const prev = { ...emptyState(), ...(wf.items[key] ?? {}) }
const next = { ...prev, steps: { ...prev.steps } }
const body = req.body ?? {}
if (body.steps && typeof body.steps === 'object') {
for (const s of STEPS) {
if (typeof body.steps[s] === 'boolean') {
next.steps[s] = body.steps[s] ? (prev.steps[s] ?? new Date().toISOString()) : null
}
}
}
if (typeof body.notes === 'string') next.notes = body.notes.slice(0, 5000)
wf.items[key] = next
await save(wf)
res.json({ ok: true, state: next })
})
}
+113 -20
View File
@@ -1,4 +1,5 @@
import express from 'express' import express from 'express'
import { applyFeedRelease } from '../feed-release.js'
import path from 'node:path' import path from 'node:path'
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import { escapeHtml, escapeXml, injectSeoIntoHtml, normalizeSitemapPath } from '../helpers.js' import { escapeHtml, escapeXml, injectSeoIntoHtml, normalizeSitemapPath } from '../helpers.js'
@@ -11,11 +12,38 @@ import {
DEFAULT_SEO, DEFAULT_SEO,
APP_VERSION, APP_VERSION,
GIT_COMMIT, GIT_COMMIT,
DATA_FILE,
} from '../config.js' } from '../config.js'
import { state } from '../state.js' import { state } from '../state.js'
import { loadSiteContentFile } from '../data.js' import { loadSiteContentFile } from '../data.js'
import { DATA_FILE } from '../config.js' import { sanitizeRedirectRules, isKnownSitePath } from '../study-helpers.js'
import { sanitizeRedirectRules } from '../study-helpers.js' import { fetchAllEpisodes } from './episodes.js'
import { fetchNuggetsEpisodes } from './nuggets.js'
import { BIBLE_BOOKS } from '../../shared/bible-books.js'
// Build a regex that matches scripture references like "Titus 1:5-9" or "1 Cor 13:4-7"
// out of plain text. Sorted longest-first so "1 Corinthians" beats "1 Cor".
const _refPatterns = []
for (const b of BIBLE_BOOKS) {
_refPatterns.push(b.name, ...(b.aliases ?? []))
}
_refPatterns.sort((a, z) => z.length - a.length)
const _bookPart = _refPatterns.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
const SCRIPTURE_REF_RE = new RegExp(
`(${_bookPart})\\s+(\\d+):(\\d+)(?:-((?:\\d+):)?(\\d+))?`,
'gi',
)
function extractScriptureRefs(text) {
const refs = []
const seen = new Set()
for (const m of String(text || '').matchAll(SCRIPTURE_REF_RE)) {
const ref = m[0].trim()
const key = ref.toLowerCase()
if (!seen.has(key)) { seen.add(key); refs.push(ref) }
}
return refs
}
export function register(app) { export function register(app) {
const SALVATION_INDEX_FILE = path.join(DIST_DIR, 'salvation', 'index.html') const SALVATION_INDEX_FILE = path.join(DIST_DIR, 'salvation', 'index.html')
@@ -32,13 +60,14 @@ export function register(app) {
content = parsed.siteContent content = parsed.siteContent
} catch { content = {} } } catch { content = {} }
} }
content = await applyFeedRelease(content)
const seo = content?.seo ?? DEFAULT_SEO const seo = content?.seo ?? DEFAULT_SEO
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
res.type('text/plain').send( res.type('text/plain').send(
['User-agent: *', 'Allow: /', 'Disallow: /salvation', `Sitemap: ${root}/sitemap.xml`].join('\n'), ['User-agent: *', 'Allow: /', `Sitemap: ${root}/sitemap.xml`].join('\n'),
) )
}) })
@@ -75,10 +104,20 @@ export function register(app) {
} }
} }
// Dynamic episode highlight URLs // Dynamic episode highlight URLs
const highlights = Array.isArray(state.cachedSiteContent?.podcastFeaturedLinks) ? state.cachedSiteContent.podcastFeaturedLinks : [] const highlights = [
...(Array.isArray(state.cachedSiteContent?.podcastFeaturedLinks) ? state.cachedSiteContent.podcastFeaturedLinks : []),
...(Array.isArray(state.cachedSiteContent?.finishedBooks) ? state.cachedSiteContent.finishedBooks.flatMap(book => (Array.isArray(book?.highlights) ? book.highlights : [])) : []),
]
for (const ep of highlights) { for (const ep of highlights) {
if (ep.id) urls.push(`${root}/episodes/${encodeURIComponent(ep.id)}`) if (ep.id) urls.push(`${root}/episodes/${encodeURIComponent(ep.id)}`)
} }
// The standalone gospel page
if (!urls.includes(`${root}/salvation`)) urls.push(`${root}/salvation`)
// Newsletter archive + published issues
urls.push(`${root}/newsletter`)
for (const issue of state.newsletterIssues) {
if (issue.slug && issue.public !== false) urls.push(`${root}/newsletter/${encodeURIComponent(issue.slug)}`)
}
const xml = [ const xml = [
'<?xml version="1.0" encoding="UTF-8"?>', '<?xml version="1.0" encoding="UTF-8"?>',
@@ -116,7 +155,8 @@ export function register(app) {
if (!id || !/^[\w-]{1,120}$/.test(id)) { if (!id || !/^[\w-]{1,120}$/.test(id)) {
res.redirect(302, '/questions'); return res.redirect(302, '/questions'); return
} }
const sourceQuestions = state.questions // Same source as /api/questions, so a freshly published answer's share link works straight away
const sourceQuestions = state.draftQuestions ?? state.questions
const question = sourceQuestions.find(q => q.id === id && q.isApproved === true && q.answer) const question = sourceQuestions.find(q => q.id === id && q.isApproved === true && q.answer)
if (!question) { if (!question) {
res.redirect(302, '/questions'); return res.redirect(302, '/questions'); return
@@ -159,23 +199,74 @@ export function register(app) {
</html>`) </html>`)
}) })
// Scripture index — all released study lessons with their passage references // Scripture index — every passage covered across released study lessons and podcast episodes
app.get('/api/public/scripture-index', (_req, res) => { app.get('/api/public/scripture-index', async (_req, res) => {
const content = state.cachedSiteContent const content = await applyFeedRelease(state.cachedSiteContent)
const allStudies = Array.isArray(content?.studies) ? content.studies : [] const allStudies = Array.isArray(content?.studies) ? content.studies : []
const allEpisodes = [
...(Array.isArray(content?.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []),
...(Array.isArray(content?.finishedBooks) ? content.finishedBooks.flatMap(book => (Array.isArray(book?.highlights) ? book.highlights : [])) : []),
]
const cutoff = new Date() const cutoff = new Date()
const studies = allStudies const entries = []
.filter(s => s.slug && Array.isArray(s.sections))
.map(s => ({ for (const s of allStudies) {
slug: s.slug, if (!s.slug || !Array.isArray(s.sections)) continue
title: s.title || s.slug, const bookName = s.bibleBook || s.slug
entries: s.sections for (const sec of s.sections) {
.filter(sec => sec.id && sec.reference && sec.releasedAt && new Date(sec.releasedAt) <= cutoff) if (!sec.id || !sec.reference || !sec.releasedAt) continue
.map(sec => ({ sectionId: sec.id, title: sec.title || '', reference: sec.reference })) if (new Date(sec.releasedAt) > cutoff) continue
.sort((a, b) => a.reference.localeCompare(b.reference, undefined, { numeric: true })), entries.push({
})) // Section references often already include the book ("Titus 1:1-2"); only prepend when they don't
.filter(s => s.entries.length > 0) reference: /^\s*(?:[1-3]\s*)?[A-Za-z]/.test(sec.reference) ? sec.reference.trim() : `${bookName} ${sec.reference}`,
res.json({ studies }) sourceType: 'study',
sourceTitle: s.title || s.slug,
sectionTitle: sec.title || '',
url: `/study/${s.slug}/${sec.id}`,
})
}
}
for (const ep of allEpisodes) {
if (!ep.id || !Array.isArray(ep.showNotesScripture)) continue
for (const ref of ep.showNotesScripture) {
if (!ref || !ref.trim()) continue
entries.push({
reference: ref.trim(),
sourceType: 'episode',
sourceTitle: ep.episodeNumber ? `Episode ${ep.episodeNumber}: ${ep.title}` : (ep.title || 'Episode'),
url: `/episodes/${ep.id}`,
})
}
}
// Extract scripture refs from main podcast episode descriptions
try {
const mainEps = await fetchAllEpisodes()
for (const ep of mainEps) {
if (!ep.description) continue
const refs = extractScriptureRefs(ep.description)
for (const ref of refs) {
const title = ep.episode ? `Episode ${ep.episode}: ${ep.title}` : ep.title
entries.push({ reference: ref, sourceType: 'episode', sourceTitle: title, url: ep.link || '/episodes' })
}
}
} catch { /* keep going if RSS is down */ }
// Extract scripture refs from Nuggets episode descriptions
try {
const nuggetsEps = await fetchNuggetsEpisodes()
for (const ep of nuggetsEps) {
if (!ep.description) continue
const refs = extractScriptureRefs(ep.description)
for (const ref of refs) {
const title = ep.episode ? `Nuggets Ep. ${ep.episode}: ${ep.title}` : ep.title
entries.push({ reference: ref, sourceType: 'episode', sourceTitle: title, url: '/nuggets' })
}
}
} catch { /* keep going if RSS is down */ }
res.json({ entries })
}) })
// SPA static files and fallback // SPA static files and fallback
@@ -184,6 +275,8 @@ export function register(app) {
app.use(async (_req, res) => { app.use(async (_req, res) => {
try { try {
const html = await readFile(INDEX_FILE, 'utf8') const html = await readFile(INDEX_FILE, 'utf8')
// Unknown URLs still render the app's "Page not found" screen, but with a real 404 status
if (!isKnownSitePath(_req.path)) res.status(404)
res.type('html').send(injectSeoIntoHtml(html, state.cachedSiteContent)) res.type('html').send(injectSeoIntoHtml(html, state.cachedSiteContent))
} catch { } catch {
res.status(503).send('Frontend build not found. Run "npm run build" first.') res.status(503).send('Frontend build not found. Run "npm run build" first.')
+128 -31
View File
@@ -4,8 +4,66 @@ import { MAX_QUESTIONS } from '../config.js'
import { state } from '../state.js' import { state } from '../state.js'
import { queueQuestionsWrite, queueDraftQuestionsWrite } from '../data.js' import { queueQuestionsWrite, queueDraftQuestionsWrite } from '../data.js'
import { buildQuestionAnsweredEmailTemplate, getResendFromAddress, getResendReplyToAddress, sendResendEmailWithRetry, getCanonicalBaseUrl } from '../email.js' import { buildQuestionAnsweredEmailTemplate, getResendFromAddress, getResendReplyToAddress, sendResendEmailWithRetry, getCanonicalBaseUrl } from '../email.js'
import { buildAbsoluteUrl } from '../helpers.js' import { buildAbsoluteUrl, getClientIp } from '../helpers.js'
import { Resend } from 'resend' import { Resend } from 'resend'
import rateLimit from 'express-rate-limit'
const upvoteRateLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many votes. Please try again later.' },
})
// One upvote per client per question until the process restarts.
const upvotedBy = new Set()
const UPVOTED_BY_MAX = 50000
// Only these fields are ever sent to the public; the stored record also holds the asker's email.
function toPublicQuestion(q) {
return {
id: q.id,
firstName: q.firstName,
question: q.question,
answer: q.answer,
topic: q.topic || '',
reference: q.reference || '',
submittedAt: q.submittedAt,
answeredAt: q.answeredAt,
upvotes: q.upvotes ?? 0,
pinned: q.pinned === true,
}
}
const clean = (value, max) => (typeof value === 'string' ? value.trim().slice(0, max) : '')
// Email the asker once when their answer goes live (if they asked to be told).
async function notifyAsker(question) {
if (!question.notifyOnAnswer || !question.email || !question.answer || question.notifiedAt) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const from = getResendFromAddress()
const replyTo = getResendReplyToAddress()
const baseUrl = getCanonicalBaseUrl()
const questionUrl = buildAbsoluteUrl(baseUrl, `/questions#qa-${question.id}`)
const template = buildQuestionAnsweredEmailTemplate({
firstName: question.firstName,
question: question.question,
answer: question.answer,
questionUrl,
})
await sendResendEmailWithRetry({
resend,
payload: { from, replyTo, to: question.email, subject: template.subject, text: template.text, html: template.html },
context: `notify-on-answer:${question.id}`,
})
question.notifiedAt = new Date().toISOString()
queueDraftQuestionsWrite()
} catch (err) {
console.error('[notify-on-answer] Failed to send notification email:', err?.message ?? err)
}
}
function ensureDraftQuestions() { function ensureDraftQuestions() {
if (state.draftQuestions !== null) return if (state.draftQuestions !== null) return
@@ -18,12 +76,14 @@ export function register(app) {
const publicQuestions = sourceQuestions const publicQuestions = sourceQuestions
.filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0) .filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0)
.sort((a, b) => { .sort((a, b) => {
// Pinned questions always float to top // Pinned first (they are also the ones featured on the home page), then newest answer first
if (a.pinned && !b.pinned) return -1 if (a.pinned && !b.pinned) return -1
if (!a.pinned && b.pinned) return 1 if (!a.pinned && b.pinned) return 1
return 0 const at = new Date(a.answeredAt ?? a.submittedAt ?? 0).getTime() || 0
const bt = new Date(b.answeredAt ?? b.submittedAt ?? 0).getTime() || 0
return bt - at
}) })
res.json({ questions: publicQuestions }) res.json({ questions: publicQuestions.map(toPublicQuestion) })
}) })
app.get('/api/admin-questions', requireAdminAuth, (_req, res) => { app.get('/api/admin-questions', requireAdminAuth, (_req, res) => {
@@ -62,6 +122,8 @@ export function register(app) {
answeredAt: answerText ? now : null, answeredAt: answerText ? now : null,
isApproved: approveNow, isApproved: approveNow,
approvedAt: approveNow ? now : null, approvedAt: approveNow ? now : null,
topic: clean(req.body?.topic, 60),
reference: clean(req.body?.reference, 80),
} }
state.draftQuestions.unshift(created) state.draftQuestions.unshift(created)
@@ -106,32 +168,60 @@ export function register(app) {
question.approvedAt = approved === true ? new Date().toISOString() : null question.approvedAt = approved === true ? new Date().toISOString() : null
queueDraftQuestionsWrite() queueDraftQuestionsWrite()
if (approved === true && question.notifyOnAnswer && question.email && question.answer) { if (approved === true) await notifyAsker(question)
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const from = getResendFromAddress()
const replyTo = getResendReplyToAddress()
const baseUrl = getCanonicalBaseUrl()
const questionUrl = buildAbsoluteUrl(baseUrl, `/questions#qa-${question.id}`)
const template = buildQuestionAnsweredEmailTemplate({
firstName: question.firstName,
question: question.question,
answer: question.answer,
questionUrl,
})
await sendResendEmailWithRetry({
resend,
payload: { from, replyTo, to: question.email, subject: template.subject, text: template.text, html: template.html },
context: `notify-on-answer:${question.id}`,
})
} catch (err) {
console.error('[notify-on-answer] Failed to send notification email:', err?.message ?? err)
}
}
res.json({ ok: true, question }) res.json({ ok: true, question })
}) })
// Edit any part of a question (typo fixes, answer, topic, scripture reference)
app.patch('/api/admin-questions/:id', requireAdminAuth, (req, res) => {
ensureDraftQuestions()
const question = state.draftQuestions.find(q => q.id === req.params.id)
if (!question) { res.status(404).json({ message: 'Question not found.' }); return }
const body = req.body ?? {}
if (typeof body.question === 'string') {
const text = body.question.trim()
if (text.length < 5 || text.length > 3000) { res.status(400).json({ message: 'Question must be between 5 and 3000 characters.' }); return }
question.question = text
}
if (typeof body.answer === 'string') {
const text = body.answer.trim()
if (text.length > 5000) { res.status(400).json({ message: 'Answer must be 5000 characters or fewer.' }); return }
if (text !== (question.answer ?? '')) question.answeredAt = text ? new Date().toISOString() : null
question.answer = text
}
if (typeof body.firstName === 'string' && body.firstName.trim()) question.firstName = clean(body.firstName, 100)
if (typeof body.topic === 'string') question.topic = clean(body.topic, 60)
if (typeof body.reference === 'string') question.reference = clean(body.reference, 80)
queueDraftQuestionsWrite()
res.json({ ok: true, question })
})
// Save the answer, make it public and email the asker (once) — all in one step
app.post('/api/admin-questions/:id/publish', requireAdminAuth, async (req, res) => {
ensureDraftQuestions()
const question = state.draftQuestions.find(q => q.id === req.params.id)
if (!question) { res.status(404).json({ message: 'Question not found.' }); return }
const body = req.body ?? {}
if (typeof body.answer === 'string') {
const text = body.answer.trim()
if (text.length > 5000) { res.status(400).json({ message: 'Answer must be 5000 characters or fewer.' }); return }
if (text !== (question.answer ?? '')) question.answeredAt = text ? new Date().toISOString() : null
question.answer = text
}
if (typeof body.topic === 'string') question.topic = clean(body.topic, 60)
if (typeof body.reference === 'string') question.reference = clean(body.reference, 80)
if (!question.answer?.trim()) { res.status(400).json({ message: 'Write an answer before publishing.' }); return }
question.isApproved = true
question.approvedAt = new Date().toISOString()
queueDraftQuestionsWrite()
await notifyAsker(question)
res.json({ ok: true, question })
})
app.delete('/api/admin-questions/:id', requireAdminAuth, (req, res) => { app.delete('/api/admin-questions/:id', requireAdminAuth, (req, res) => {
const { id } = req.params const { id } = req.params
ensureDraftQuestions() ensureDraftQuestions()
@@ -148,15 +238,22 @@ export function register(app) {
}) })
// Anonymous upvote — no auth required, lightweight increment // Anonymous upvote — no auth required, lightweight increment
app.post('/api/questions/:id/upvote', (req, res) => { app.post('/api/questions/:id/upvote', upvoteRateLimiter, (req, res) => {
const { id } = req.params const { id } = req.params
// Look in live questions first, then draft // Only approved questions are public, so only they can be upvoted.
const liveQ = state.questions.find(q => q.id === id && q.isApproved === true) const source = state.draftQuestions ?? state.questions
const draftQ = state.draftQuestions ? state.draftQuestions.find(q => q.id === id) : null const question = source.find(q => q.id === id && q.isApproved === true)
const question = liveQ ?? draftQ const liveQ = question && state.questions.includes(question) ? question : null
const draftQ = question && state.draftQuestions?.includes(question) ? question : null
if (!question) { if (!question) {
res.status(404).json({ message: 'Question not found.' }); return res.status(404).json({ message: 'Question not found.' }); return
} }
const voteKey = `${getClientIp(req)}|${id}`
if (upvotedBy.has(voteKey)) {
res.json({ ok: true, upvotes: question.upvotes ?? 0, duplicate: true }); return
}
if (upvotedBy.size >= UPVOTED_BY_MAX) upvotedBy.clear()
upvotedBy.add(voteKey)
question.upvotes = ((question.upvotes ?? 0) + 1) question.upvotes = ((question.upvotes ?? 0) + 1)
if (liveQ) queueQuestionsWrite() if (liveQ) queueQuestionsWrite()
if (draftQ) queueDraftQuestionsWrite() if (draftQ) queueDraftQuestionsWrite()
+307
View File
@@ -0,0 +1,307 @@
import { Resend } from 'resend'
import { escapeHtml, sanitizeUrl } from '../helpers.js'
import { state } from '../state.js'
import { getResendFromAddress, getResendInboxAddress, getResendReplyToAddress, getCanonicalBaseUrl, sendResendEmailWithRetry } from '../email.js'
// A visitor who finished the /salvation page can tell us they prayed to receive Christ.
// Nothing is stored on the server: the response is emailed once to the site inbox and that is all.
const hits = new Map()
// One confirmation per address per hour, so the form can't be used to mail someone repeatedly.
const confirmedAt = new Map()
const CONFIRM_COOLDOWN_MS = 60 * 60 * 1000
function mayConfirm(address) {
const key = address.toLowerCase()
const now = Date.now()
const last = confirmedAt.get(key)
if (typeof last === 'number' && now - last < CONFIRM_COOLDOWN_MS) return false
confirmedAt.set(key, now)
if (confirmedAt.size > 2000) {
for (const [k, v] of confirmedAt) { if (now - v > CONFIRM_COOLDOWN_MS) confirmedAt.delete(k) }
}
return true
}
function rateLimit(req, res, next) {
const ip = req.ip ?? 'unknown'
const now = Date.now()
const windowMs = 10 * 60 * 1000
const entry = hits.get(ip) ?? { count: 0, start: now }
if (now - entry.start > windowMs) { entry.count = 0; entry.start = now }
entry.count += 1
hits.set(ip, entry)
if (hits.size > 2000) {
for (const [k, v] of hits) { if (now - v.start > windowMs) hits.delete(k) }
}
if (entry.count > 3) {
res.status(429).json({ message: 'Please wait a few minutes before trying again.' })
return
}
next()
}
function clean(value, max) {
return typeof value === 'string' ? value.trim().replace(/\s+/g, ' ').slice(0, max) : ''
}
// The notification the site owner receives. Styled to match the other admin emails
// (dark header, gold accent, cream cards) and written to be acted on, not just read.
function buildSalvationEmail({ name, email, message, when }) {
const who = name || 'Someone'
const subject = name ? `A new decision for Christ: ${name}` : 'A new decision for Christ on your salvation page'
const nameLine = name || 'Name not given'
const safeName = escapeHtml(nameLine)
const safeEmail = escapeHtml(email)
const safeWhen = escapeHtml(when)
const followUp = email
? [
'Reply to this email within a day or two. A short, warm note is plenty.',
'Congratulate them, and ask them to tell you their story.',
'Point them to the Gospel of John, a Bible-teaching church, and baptism.',
'Invite them to Start Here at versebyversewithnate.us/start-here.',
'Pray for them by name.',
]
: ['They did not leave an email address, so there is no way to reply.', 'Pray for them. God knows exactly who they are.']
const text = [
`A VERDICT HAS COME IN`,
``,
`${who} pleaded guilty and accepted Christ's pardon on the "The Gospel, in a Courtroom" page and says they prayed to receive Jesus as their Savior.`,
``,
`"There is joy in the presence of the angels of God over one sinner who repents." (Luke 15:10)`,
``,
`DETAILS`,
`Name: ${nameLine}`,
`Email: ${email || 'Not given'}`,
`When: ${when}`,
``,
`THEIR MESSAGE`,
message || '(They did not leave a message.)',
``,
`SUGGESTED FOLLOW-UP`,
...followUp.map((t, i) => `${i + 1}. ${t}`),
``,
`This email is the only record of this response. Nothing is stored on the website, so keep it somewhere safe.`,
email ? `Reply directly to this email to reach ${who}.` : '',
].filter(l => l !== '').join('\n')
const row = (label, value, last = false) =>
`<tr><td style="width:96px;padding:11px 0;font-family:Arial,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#8a6d35;${last ? '' : 'border-bottom:1px solid #efe4cc;'}">${label}</td>` +
`<td style="padding:11px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;${last ? '' : 'border-bottom:1px solid #efe4cc;'}">${value}</td></tr>`
const steps = followUp
.map(t => `<li style="margin:0 0 8px;font-family:Arial,sans-serif;font-size:15px;line-height:1.55;color:#201a10;">${escapeHtml(t)}</li>`)
.join('')
const messageBlock = message
? `<tr><td style="padding:0 28px 22px;">
<div style="font-family:Arial,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:#8a6d35;margin:0 0 8px;">Their message</div>
<div style="background:#fbf7ef;border-left:4px solid #c8860a;border-radius:4px;padding:16px 18px;font-family:Georgia,serif;font-size:16px;line-height:1.7;color:#201a10;white-space:pre-wrap;">${escapeHtml(message)}</div>
</td></tr>`
: `<tr><td style="padding:0 28px 22px;font-family:Arial,sans-serif;font-size:14px;color:#735a2b;font-style:italic;">They did not leave a message.</td></tr>`
const replyButton = email
? `<tr><td align="center" style="padding:0 28px 26px;">
<a href="mailto:${safeEmail}?subject=${encodeURIComponent('Welcome to the family of God')}" style="display:inline-block;background:#c8860a;color:#111111;font-family:Arial,sans-serif;font-size:14px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;text-decoration:none;padding:14px 30px;border-radius:4px;">Reply to ${escapeHtml(who)}</a>
</td></tr>`
: ''
const html = `<!doctype html><html><body style="margin:0;padding:0;background:#f5f1e8;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background:#f5f1e8;"><tr><td align="center" style="padding:28px 14px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:640px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
<tr><td style="background:#111111;padding:30px 28px 26px;text-align:center;border-bottom:4px solid #c8860a;">
<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.34em;text-transform:uppercase;color:#c8860a;">The Court of Heaven</div>
<div style="font-size:40px;line-height:1;margin:14px 0 6px;color:#c8860a;">&#9878;</div>
<h1 style="margin:0;font-family:Georgia,serif;font-size:30px;line-height:1.2;color:#f4ead5;">A Verdict Has Come In</h1>
<p style="margin:10px 0 0;font-family:Georgia,serif;font-style:italic;font-size:17px;color:#d9c9a3;">${escapeHtml(who)} pleaded guilty and accepted the pardon.</p>
</td></tr>
<tr><td style="padding:28px 28px 8px;">
<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:17px;line-height:1.7;color:#201a10;">Someone just worked through <em>The Gospel, in a Courtroom</em> on your website, pleaded guilty, and says they prayed to receive Jesus as their Savior.</p>
<p style="margin:0 0 20px;font-family:Georgia,serif;font-style:italic;font-size:16px;line-height:1.6;color:#57452b;border-left:3px solid #c8860a;padding-left:14px;">&ldquo;There is joy in the presence of the angels of God over one sinner who repents.&rdquo; <span style="font-style:normal;font-size:13px;color:#8a6d35;">Luke 15:10</span></p>
</td></tr>
<tr><td style="padding:0 28px 22px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="border-top:2px solid #c8860a;">
${row('Name', safeName)}
${row('Email', email ? `<a href="mailto:${safeEmail}" style="color:#8f5f05;text-decoration:none;">${safeEmail}</a>` : '<span style="color:#735a2b;font-style:italic;">Not given</span>')}
${row('When', safeWhen, true)}
</table>
</td></tr>
${messageBlock}
${replyButton}
<tr><td style="padding:0 28px 26px;">
<div style="background:#f7f2e5;border:1px solid #e8dcc1;border-radius:10px;padding:18px 22px;">
<div style="font-family:Arial,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:#8a6d35;margin:0 0 12px;">Suggested follow-up</div>
<ol style="margin:0;padding-left:20px;">${steps}</ol>
</div>
</td></tr>
<tr><td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:16px 28px;">
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.6;color:#735a2b;"><strong>Keep this email.</strong> It is the only record of this response. Nothing is stored on the website. ${email ? 'Replying to this message goes straight to them.' : ''}</p>
</td></tr>
</table>
</td></tr></table></body></html>`
return { subject, text, html }
}
// The warm, one-time confirmation the visitor receives at the address they gave.
function buildSalvationConfirmationEmail({ name }) {
const base = getCanonicalBaseUrl().replace(/\/$/, '')
const greeting = name ? `Hi ${name},` : 'Hi friend,'
const subject = 'We received your message. Welcome to the family of God'
const steps = [
['Read the Gospel of John.', 'Start there, then keep going through the New Testament.'],
['Talk to God every day.', 'Prayer is simply talking with him, in your own words.'],
['Tell someone.', 'Share your decision with a Christian friend or family member.'],
['Find a church.', 'Join a Bible-teaching church, and be baptized as a public sign of your faith (Acts 2:38).'],
]
const text = [
greeting,
'',
'Thank you for letting us know that you prayed to receive Jesus. We are rejoicing with you, and your message has been received.',
'Someone from our team will reach out to you personally soon.',
'',
'"Therefore, there is now no condemnation for those who are in Christ Jesus." (Romans 8:1)',
'You are no longer a defendant. You are a child of the Judge.',
'',
'WHAT TO DO NEXT',
...steps.map(([h, b], i) => `${i + 1}. ${h} ${b}`),
'',
`A good path through the podcast and studies: ${base}/start-here`,
`Questions? Ask here: ${base}/contact?source=salvation`,
'',
'Grace and peace,',
'Verse by Verse with Nate',
'',
`You are receiving this one-time email because this address was entered on ${base}/salvation. If that was not you, you can safely ignore it.`,
].join('\n')
const stepRows = steps
.map(([h, b], i) =>
`<tr><td valign="top" style="width:34px;padding:0 0 14px;"><div style="width:26px;height:26px;border-radius:13px;background:#c8860a;color:#111;font-family:Arial,sans-serif;font-size:13px;font-weight:700;line-height:26px;text-align:center;">${i + 1}</div></td>` +
`<td style="padding:2px 0 14px;font-family:Arial,sans-serif;font-size:15px;line-height:1.55;color:#201a10;"><strong>${escapeHtml(h)}</strong> ${escapeHtml(b)}</td></tr>`)
.join('')
const html = `<!doctype html><html><body style="margin:0;padding:0;background:#f5f1e8;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background:#f5f1e8;"><tr><td align="center" style="padding:28px 14px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:600px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
<tr><td style="background:#111111;padding:30px 28px 26px;text-align:center;border-bottom:4px solid #c8860a;">
<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.34em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>
<div style="width:54px;height:54px;border-radius:27px;background:#c8860a;color:#111111;font-family:Arial,sans-serif;font-size:30px;font-weight:700;line-height:54px;margin:16px auto 10px;">&#10003;</div>
<h1 style="margin:0;font-family:Georgia,serif;font-size:28px;line-height:1.25;color:#f4ead5;">Your message was received</h1>
<p style="margin:10px 0 0;font-family:Georgia,serif;font-style:italic;font-size:17px;color:#d9c9a3;">Welcome to the family of God.</p>
</td></tr>
<tr><td style="padding:30px 28px 6px;">
<p style="margin:0 0 16px;font-family:Georgia,serif;font-size:18px;line-height:1.6;color:#201a10;">${escapeHtml(greeting)}</p>
<p style="margin:0 0 16px;font-family:Georgia,serif;font-size:17px;line-height:1.7;color:#201a10;">Thank you for letting us know that you prayed to receive Jesus. We are rejoicing with you, and your message has been received.</p>
<p style="margin:0 0 22px;font-family:Georgia,serif;font-size:17px;line-height:1.7;color:#201a10;"><strong>Someone from our team will reach out to you personally soon.</strong></p>
</td></tr>
<tr><td style="padding:0 28px 26px;">
<div style="background:#fbf7ef;border-left:4px solid #c8860a;border-radius:4px;padding:18px 20px;">
<p style="margin:0 0 8px;font-family:Georgia,serif;font-style:italic;font-size:17px;line-height:1.6;color:#201a10;">&ldquo;Therefore, there is now no condemnation for those who are in Christ Jesus.&rdquo; <span style="font-style:normal;font-size:13px;color:#8a6d35;">Romans 8:1</span></p>
<p style="margin:0;font-family:Georgia,serif;font-size:15px;line-height:1.6;color:#57452b;">You are no longer a defendant. You are a child of the Judge.</p>
</div>
</td></tr>
<tr><td style="padding:0 28px 8px;">
<div style="font-family:Arial,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:#8a6d35;margin:0 0 14px;">What to do next</div>
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation">${stepRows}</table>
</td></tr>
<tr><td align="center" style="padding:6px 28px 8px;">
<a href="${escapeHtml(base)}/start-here" style="display:inline-block;background:#c8860a;color:#111111;font-family:Arial,sans-serif;font-size:14px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;text-decoration:none;padding:14px 28px;border-radius:4px;">Start Here</a>
</td></tr>
<tr><td align="center" style="padding:6px 28px 26px;font-family:Arial,sans-serif;font-size:14px;color:#57452b;">
Have a question? <a href="${escapeHtml(base)}/contact?source=salvation" style="color:#8f5f05;">Ask us here</a>, or just reply to this email.
</td></tr>
<tr><td style="padding:0 28px 28px;">
<p style="margin:0;font-family:Georgia,serif;font-size:16px;line-height:1.6;color:#201a10;">Grace and peace,<br><strong>Verse by Verse with Nate</strong></p>
</td></tr>
<tr><td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:16px 28px;">
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.6;color:#735a2b;">You are receiving this one-time email because this address was entered at ${escapeHtml(base)}/salvation. If that was not you, you can safely ignore it.</p>
</td></tr>
</table>
</td></tr></table></body></html>`
return { subject, text, html }
}
export function register(app) {
// The /salvation page is static HTML, so it asks the server for the narration URLs set in Admin → Gospel Page.
app.get('/api/salvation-audio', (_req, res) => {
const c = state.cachedSiteContent ?? {}
res.set('Cache-Control', 'public, max-age=300')
res.json({
url: sanitizeUrl(c.salvationAudioUrl) || '',
guiltyUrl: sanitizeUrl(c.salvationAudioGuiltyUrl) || '',
notGuiltyUrl: sanitizeUrl(c.salvationAudioNotGuiltyUrl) || '',
})
})
app.post('/api/salvation-response', rateLimit, async (req, res) => {
const { name, email, message, _honey } = req.body ?? {}
if (_honey) { res.json({ ok: true }); return }
const safeName = clean(name, 100)
const safeEmail = clean(email, 200)
const safeMessage = typeof message === 'string' ? message.trim().slice(0, 2000) : ''
if (safeEmail && !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(safeEmail)) {
res.status(400).json({ message: 'That email address does not look right. You can also leave it blank.' })
return
}
if (!process.env.RESEND_API_KEY) {
console.error('[salvation] RESEND_API_KEY env var not set')
res.status(503).json({ message: 'We could not send this right now.' })
return
}
const when = new Date().toLocaleString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: 'America/New_York', timeZoneName: 'short' })
const { subject, text, html } = buildSalvationEmail({ name: safeName, email: safeEmail, message: safeMessage, when })
try {
const resend = new Resend(process.env.RESEND_API_KEY)
await sendResendEmailWithRetry({
resend,
context: 'salvation-response',
payload: {
from: getResendFromAddress(),
to: [getResendInboxAddress()],
...(safeEmail ? { replyTo: safeEmail } : {}),
subject,
tags: [{ name: 'flow', value: 'salvation-response' }],
text,
html,
},
})
// Best effort: a failed or skipped confirmation must never turn a delivered decision into an error.
let confirmationSent = false
if (safeEmail && mayConfirm(safeEmail)) {
try {
const confirmation = buildSalvationConfirmationEmail({ name: safeName })
await sendResendEmailWithRetry({
resend,
context: 'salvation-confirmation',
payload: {
from: getResendFromAddress(),
to: [safeEmail],
replyTo: getResendReplyToAddress(),
subject: confirmation.subject,
tags: [{ name: 'flow', value: 'salvation-confirmation' }],
text: confirmation.text,
html: confirmation.html,
},
})
confirmationSent = true
} catch (confirmErr) {
console.error('[salvation] confirmation email failed:', confirmErr?.message ?? confirmErr)
}
}
res.json({ ok: true, confirmationSent })
} catch (err) {
console.error('[salvation] email failed:', err?.message ?? err)
res.status(502).json({ message: 'We could not send this right now.' })
}
})
}
+73 -34
View File
@@ -1,10 +1,11 @@
import { noteEntriesToText } from '../../shared/note-format.js'
import { randomUUID, timingSafeEqual } from 'node:crypto' import { randomUUID, timingSafeEqual } from 'node:crypto'
import rateLimit from 'express-rate-limit' import rateLimit from 'express-rate-limit'
import { Document, Packer, Paragraph, HeadingLevel, TextRun } from 'docx' import { Document, Packer, Paragraph, HeadingLevel, TextRun } from 'docx'
import { mkdir, unlink, writeFile } from 'node:fs/promises' import { mkdir, unlink, writeFile } from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { inferImageExtensionFromDataUrl, normalizeAssetBaseName, escapeHtml, buildAbsoluteUrl } from '../helpers.js' import { inferImageExtensionFromDataUrl, normalizeAssetBaseName, escapeHtml, buildAbsoluteUrl, parseCookies } from '../helpers.js'
import { UPLOADS_DIR, EMAIL_CHANGE_TOKEN_TTL_MS } from '../config.js' import { UPLOADS_DIR, EMAIL_CHANGE_TOKEN_TTL_MS, MAX_STUDY_NOTES_PER_USER, MAX_STUDY_NOTE_LENGTH, STUDY_SESSION_COOKIE } from '../config.js'
import { state } from '../state.js' import { state } from '../state.js'
import { import {
queueStudyUsersWrite, queueStudyUsersWrite,
@@ -21,22 +22,27 @@ import {
import { import {
requireStudyAuth, requireStudyAuth,
hashStudyPassword, hashStudyPassword,
verifyStudyPassword,
hashEmailChangeToken, hashEmailChangeToken,
normalizeStudyUsername, normalizeStudyUsername,
isValidStudyUsername, isValidStudyUsername,
findStudyUserByUsername, findStudyUserByUsername,
getStudyAvatarUrl, getStudyAvatarUrl,
isStudyUserEnrolled, isStudyUserEnrolled,
isStudyUserCompleted,
normalizeStudySlug, normalizeStudySlug,
getStudyCatalog, getStudyCatalog,
clearStudySessionCookie, clearStudySessionCookie,
invalidateAllStudySessions,
} from '../study-helpers.js' } from '../study-helpers.js'
import { import {
sendStudyAccountDeletedEmail, sendStudyAccountDeletedEmail,
sendStudyPasswordChangedEmail,
syncContactToResend, syncContactToResend,
buildBrandedEmailHtml, buildBrandedEmailHtml,
getCanonicalBaseUrl, getCanonicalBaseUrl,
getResendFromAddress, getResendFromAddress,
sendResendEmailWithRetry,
} from '../email.js' } from '../email.js'
import { Resend } from 'resend' import { Resend } from 'resend'
@@ -82,7 +88,7 @@ export function register(app) {
const sectionId = dashIndex >= 0 ? noteKey.slice(dashIndex + 2) : noteKey const sectionId = dashIndex >= 0 ? noteKey.slice(dashIndex + 2) : noteKey
if (!studyEntries[studySlug]) studyEntries[studySlug] = {} if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {} studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
studyEntries[studySlug][sectionId].noteText = noteText.trim() studyEntries[studySlug][sectionId].noteText = noteEntriesToText(noteText) || noteText.trim()
} }
for (const [studySlug, studyProgress] of Object.entries(progress.byStudy)) { for (const [studySlug, studyProgress] of Object.entries(progress.byStudy)) {
@@ -170,10 +176,7 @@ export function register(app) {
const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : '' const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : ''
const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : '' const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : ''
const currentHash = hashStudyPassword(currentPassword) if (!verifyStudyPassword(currentPassword, user.passwordHash)) {
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.' }) res.status(401).json({ message: 'Current password is incorrect.' })
return return
} }
@@ -186,6 +189,11 @@ export function register(app) {
user.passwordHash = hashStudyPassword(newPassword) user.passwordHash = hashStudyPassword(newPassword)
user.updatedAt = new Date().toISOString() user.updatedAt = new Date().toISOString()
queueStudyUsersWrite() queueStudyUsersWrite()
invalidateAllStudySessions(user.id, parseCookies(req.headers.cookie)[STUDY_SESSION_COOKIE])
sendStudyPasswordChangedEmail(user.username, user.displayName || user.username)
.catch(err => console.error('[study-account] password-changed email error:', err))
res.json({ ok: true }) res.json({ ok: true })
}) })
@@ -206,22 +214,38 @@ export function register(app) {
title: study.title, title: study.title,
status: study.status, status: study.status,
enrolled: isStudyUserEnrolled(user, study.slug), enrolled: isStudyUserEnrolled(user, study.slug),
completed: isStudyUserCompleted(user, study.slug),
totalLessons, totalLessons,
completedLessons, completedLessons,
noteCount, noteCount,
} }
}) })
const baseUrl = getCanonicalBaseUrl()
const certificates = state.studyCertificates
.filter(c => c.userId === user.id)
.map(c => ({
studySlug: c.studySlug,
studyTitle: c.studyTitle,
issuedAt: c.issuedAt,
url: buildAbsoluteUrl(baseUrl, `/certificate/${c.token}`),
}))
res.json({ res.json({
profile: { profile: {
username: user.username, username: user.username,
displayName: user.displayName ?? '', displayName: user.displayName ?? '',
subscribeNewsletter: user.subscribeNewsletter !== false, subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true, studyRemindersEnabled: user.studyRemindersEnabled === true,
reminderFrequency: user.reminderFrequency ?? 'immediate',
remindersPausedUntil: user.remindersPausedUntil ?? null,
avatarUrl: getStudyAvatarUrl(user), avatarUrl: getStudyAvatarUrl(user),
emailVerified: user.emailVerified === true,
}, },
stats: { stats: {
noteCount: Object.keys(notes).length, noteCount: Object.keys(notes).length,
noteLimit: MAX_STUDY_NOTES_PER_USER,
noteCharLimit: MAX_STUDY_NOTE_LENGTH,
memberSince: user.createdAt, memberSince: user.createdAt,
lastLoginAt: user.lastLoginAt, lastLoginAt: user.lastLoginAt,
currentStreak: user.currentStreak ?? 0, currentStreak: user.currentStreak ?? 0,
@@ -229,6 +253,7 @@ export function register(app) {
lastStudiedDate: user.lastStudiedDate ?? null, lastStudiedDate: user.lastStudiedDate ?? null,
}, },
studies, studies,
certificates,
}) })
}) })
@@ -303,6 +328,20 @@ export function register(app) {
const studyRemindersEnabled = req.body?.studyRemindersEnabled === true const studyRemindersEnabled = req.body?.studyRemindersEnabled === true
user.subscribeNewsletter = subscribeNewsletter user.subscribeNewsletter = subscribeNewsletter
user.studyRemindersEnabled = studyRemindersEnabled user.studyRemindersEnabled = studyRemindersEnabled
const reminderFrequency = req.body?.reminderFrequency
if (reminderFrequency === 'weekly' || reminderFrequency === 'immediate') {
user.reminderFrequency = reminderFrequency
}
const rawPaused = req.body?.remindersPausedUntil
if (rawPaused === null || rawPaused === '') {
user.remindersPausedUntil = null
} else if (typeof rawPaused === 'string') {
const parsed = new Date(rawPaused)
user.remindersPausedUntil = !isNaN(parsed.getTime()) && parsed.getTime() > Date.now() ? parsed.toISOString() : null
}
user.updatedAt = new Date().toISOString() user.updatedAt = new Date().toISOString()
queueStudyUsersWrite() queueStudyUsersWrite()
@@ -310,7 +349,13 @@ export function register(app) {
syncContactToResend(user.displayName || user.username, user.username).catch(err => console.error('[study-account] resend sync error:', err)) syncContactToResend(user.displayName || user.username, user.username).catch(err => console.error('[study-account] resend sync error:', err))
} }
res.json({ ok: true, subscribeNewsletter: user.subscribeNewsletter, studyRemindersEnabled: user.studyRemindersEnabled === true }) res.json({
ok: true,
subscribeNewsletter: user.subscribeNewsletter,
studyRemindersEnabled: user.studyRemindersEnabled === true,
reminderFrequency: user.reminderFrequency ?? 'immediate',
remindersPausedUntil: user.remindersPausedUntil ?? null,
})
}) })
app.post('/api/study-account/request-email-change', studyAuthRateLimiter, requireStudyAuth, async (req, res) => { app.post('/api/study-account/request-email-change', studyAuthRateLimiter, requireStudyAuth, async (req, res) => {
@@ -334,10 +379,7 @@ export function register(app) {
return return
} }
const currentHash = hashStudyPassword(currentPassword) if (!verifyStudyPassword(currentPassword, user.passwordHash)) {
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.' }) res.status(401).json({ message: 'Current password is incorrect.' })
return return
} }
@@ -359,27 +401,26 @@ export function register(app) {
const emailChangeSubject = cfg.emailChangeSubject?.trim() || 'Confirm your new email address' const emailChangeSubject = cfg.emailChangeSubject?.trim() || 'Confirm your new email address'
const emailChangeBody = cfg.emailChangeBody?.trim() || 'Click the link below to confirm your new account email. If you did not request this change, ignore this message.' const emailChangeBody = cfg.emailChangeBody?.trim() || 'Click the link below to confirm your new account email. If you did not request this change, ignore this message.'
const emailChangeCtaLabel = cfg.emailChangeCtaLabel?.trim() || 'Confirm Email Change' const emailChangeCtaLabel = cfg.emailChangeCtaLabel?.trim() || 'Confirm Email Change'
const { error } = await resend.emails.send({ await sendResendEmailWithRetry({
from: getResendFromAddress(), resend,
to: [newEmail], context: 'study-account-email-change',
subject: emailChangeSubject, payload: {
text: `${emailChangeBody}\n\n${verifyUrl}`, from: getResendFromAddress(),
html: buildBrandedEmailHtml({ to: [newEmail],
title: emailChangeSubject, subject: emailChangeSubject,
eyebrow: 'Account Security', text: `${emailChangeBody}\n\n${verifyUrl}`,
bodyHtml: `<p style="margin:0 0 16px;">${escapeHtml(emailChangeBody)}</p>`, html: buildBrandedEmailHtml({
ctaLabel: emailChangeCtaLabel, title: emailChangeSubject,
ctaUrl: verifyUrl, eyebrow: 'Account Security',
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">Verse by Verse with Nate</p>`, bodyHtml: `<p style="margin:0 0 16px;">${escapeHtml(emailChangeBody)}</p>`,
}), ctaLabel: emailChangeCtaLabel,
ctaUrl: verifyUrl,
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">Verse by Verse with Nate</p>`,
}),
},
}) })
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) { } catch (err) {
console.error('[study-account] email change send exception:', err) console.error('[study-account] email change send error:', err)
res.status(503).json({ message: 'Could not send verification email right now.' }) res.status(503).json({ message: 'Could not send verification email right now.' })
return return
} }
@@ -456,9 +497,7 @@ export function register(app) {
const deletedEmail = user.username const deletedEmail = user.username
const deletedDisplayName = user.displayName || user.username const deletedDisplayName = user.displayName || user.username
for (const [token, session] of state.studySessions) { invalidateAllStudySessions(user.id)
if (session.userId === user.id) state.studySessions.delete(token)
}
state.studyUsers = state.studyUsers.filter(u => u.id !== user.id) state.studyUsers = state.studyUsers.filter(u => u.id !== user.id)
queueStudyUsersWrite() queueStudyUsersWrite()
+182 -18
View File
@@ -6,12 +6,13 @@ import {
verifyTotpCode, verifyTotpCode,
generateRecoveryCodes, generateRecoveryCodes,
} from '../auth.js' } from '../auth.js'
import { parseCookies } from '../helpers.js' import { parseCookies, buildAbsoluteUrl } from '../helpers.js'
import { STUDY_SESSION_COOKIE, MAX_STUDY_USERS, MAX_CONTACT_SUBMISSIONS } from '../config.js' import { STUDY_SESSION_COOKIE, MAX_STUDY_USERS, MAX_CONTACT_SUBMISSIONS, PASSWORD_RESET_TOKEN_TTL_MS, EMAIL_VERIFY_TOKEN_TTL_MS } from '../config.js'
import { state } from '../state.js' import { state } from '../state.js'
import { import {
queueStudyUsersWrite, queueStudyUsersWrite,
queueContactSubmissionsWrite, queueContactSubmissionsWrite,
queueStudySessionsWrite,
normalizeContactEmailStatus, normalizeContactEmailStatus,
normalizeMessageType, normalizeMessageType,
} from '../data.js' } from '../data.js'
@@ -19,20 +20,36 @@ import {
normalizeStudyUsername, normalizeStudyUsername,
isValidStudyUsername, isValidStudyUsername,
hashStudyPassword, hashStudyPassword,
verifyStudyPassword,
studyPasswordNeedsRehash,
hashPasswordResetToken,
hashEmailVerifyToken,
findStudyUserByUsername, findStudyUserByUsername,
getStudyAvatarUrl, getStudyAvatarUrl,
createStudySession, createStudySession,
setStudySessionCookie, setStudySessionCookie,
clearStudySessionCookie, clearStudySessionCookie,
requireStudyAuth, requireStudyAuth,
invalidateAllStudySessions,
createStudyTotpPendingToken, createStudyTotpPendingToken,
consumeStudyTotpPendingToken, consumeStudyTotpPendingToken,
generateEmailOtp, generateEmailOtp,
storeEmailOtp, storeEmailOtp,
verifyEmailOtp, verifyEmailOtp,
getStudyUserFromRequest, getStudyUserFromRequest,
getAccessibleStudySlugs,
} from '../study-helpers.js' } from '../study-helpers.js'
import { sendEmailOtp, sendStudyWelcomeEmail, syncContactToResend } from '../email.js' import {
sendEmailOtp,
sendStudyWelcomeEmail,
sendStudyVerifyEmail,
sendStudyPasswordResetEmail,
sendStudyPasswordChangedEmail,
syncContactToResend,
getCanonicalBaseUrl,
} from '../email.js'
const DUMMY_PASSWORD_HASH = hashStudyPassword('timing-equalizer')
const studyAuthRateLimiter = rateLimit({ const studyAuthRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
@@ -53,7 +70,13 @@ export function register(app) {
subscribeNewsletter: user?.subscribeNewsletter !== false, subscribeNewsletter: user?.subscribeNewsletter !== false,
studyRemindersEnabled: user?.studyRemindersEnabled === true, studyRemindersEnabled: user?.studyRemindersEnabled === true,
avatarUrl: user ? getStudyAvatarUrl(user) : '', avatarUrl: user ? getStudyAvatarUrl(user) : '',
enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [], // Shows an Admin link in the account menu. Only the admin panel can turn this on, and the link
// still lands on the admin sign-in, so it grants no access by itself.
adminShortcut: user?.adminShortcut === true,
// Studies the student can open: enrolled ones plus any they have finished
enrolledStudySlugs: user ? getAccessibleStudySlugs(user) : [],
completedStudySlugs: Array.isArray(user?.completedStudySlugs) ? user.completedStudySlugs : [],
emailVerified: user?.emailVerified === true,
totpEnabled: Boolean(user && (user.twoFaMethod === 'app' || user.twoFaMethod === 'email') && (user.twoFaMethod === 'email' || (user.totpSecret && user.totpVerified))), totpEnabled: Boolean(user && (user.twoFaMethod === 'app' || user.twoFaMethod === 'email') && (user.twoFaMethod === 'email' || (user.totpSecret && user.totpVerified))),
twoFaMethod: user?.twoFaMethod ?? null, twoFaMethod: user?.twoFaMethod ?? null,
totpRecoveryCodesRemaining: user?.twoFaMethod === 'app' ? (user.totpRecoveryCodes?.length ?? 0) : 0, totpRecoveryCodesRemaining: user?.twoFaMethod === 'app' ? (user.totpRecoveryCodes?.length ?? 0) : 0,
@@ -87,14 +110,21 @@ export function register(app) {
} }
const now = new Date().toISOString() const now = new Date().toISOString()
const rawVerifyToken = randomUUID()
const user = { const user = {
id: randomUUID(), id: randomUUID(),
username, username,
passwordHash: hashStudyPassword(password), passwordHash: hashStudyPassword(password),
displayName, displayName,
subscribeNewsletter: subscribe, subscribeNewsletter: subscribe,
studyRemindersEnabled: false, studyRemindersEnabled: true,
remindersDefaultedAt: now,
pendingEmailChange: null, pendingEmailChange: null,
emailVerified: false,
pendingEmailVerification: {
tokenHash: hashEmailVerifyToken(rawVerifyToken),
expiresAt: Date.now() + EMAIL_VERIFY_TOKEN_TTL_MS,
},
enrolledStudySlugs: [], enrolledStudySlugs: [],
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -105,7 +135,6 @@ export function register(app) {
queueStudyUsersWrite() queueStudyUsersWrite()
if (subscribe) { if (subscribe) {
const wantsWelcome = true
const submission = { const submission = {
id: randomUUID(), id: randomUUID(),
submittedAt: now, submittedAt: now,
@@ -113,9 +142,9 @@ export function register(app) {
email: username, email: username,
message: '', message: '',
messageType: 'general', messageType: 'general',
subscribe: wantsWelcome, subscribe: true,
archived: false, archived: false,
emailStatus: normalizeContactEmailStatus(null, wantsWelcome), emailStatus: normalizeContactEmailStatus(null, true),
} }
state.contactSubmissions.unshift(submission) state.contactSubmissions.unshift(submission)
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS) state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
@@ -124,6 +153,8 @@ export function register(app) {
} }
sendStudyWelcomeEmail(username, displayName || username).catch(err => console.error('[study-signup] welcome email error:', err)) sendStudyWelcomeEmail(username, displayName || username).catch(err => console.error('[study-signup] welcome email error:', err))
const verifyUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/verify-email?token=${encodeURIComponent(rawVerifyToken)}`)
sendStudyVerifyEmail(username, displayName || username, verifyUrl).catch(err => console.error('[study-signup] verify email error:', err))
const sessionToken = createStudySession(user.id) const sessionToken = createStudySession(user.id)
setStudySessionCookie(res, sessionToken) setStudySessionCookie(res, sessionToken)
@@ -144,18 +175,19 @@ export function register(app) {
const user = findStudyUserByUsername(username) const user = findStudyUserByUsername(username)
if (!user) { if (!user) {
verifyStudyPassword(password, DUMMY_PASSWORD_HASH) // equalize timing with the real-user path
res.status(401).json({ message: 'Invalid email or password.' }) res.status(401).json({ message: 'Invalid email or password.' })
return return
} }
const submittedHash = hashStudyPassword(password) if (!verifyStudyPassword(password, user.passwordHash)) {
const expectedHash = user.passwordHash
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 email or password.' }) res.status(401).json({ message: 'Invalid email or password.' })
return return
} }
if (studyPasswordNeedsRehash(user.passwordHash)) {
user.passwordHash = hashStudyPassword(password)
queueStudyUsersWrite()
}
const twoFaMethod = user.twoFaMethod ?? (user.totpSecret && user.totpVerified ? 'app' : null) const twoFaMethod = user.twoFaMethod ?? (user.totpSecret && user.totpVerified ? 'app' : null)
if (twoFaMethod === 'app' && user.totpSecret && user.totpVerified) { if (twoFaMethod === 'app' && user.totpSecret && user.totpVerified) {
@@ -186,7 +218,8 @@ export function register(app) {
subscribeNewsletter: user.subscribeNewsletter !== false, subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true, studyRemindersEnabled: user.studyRemindersEnabled === true,
avatarUrl: getStudyAvatarUrl(user.username), avatarUrl: getStudyAvatarUrl(user.username),
enrolledStudySlugs: user.enrolledStudySlugs ?? [], enrolledStudySlugs: getAccessibleStudySlugs(user),
completedStudySlugs: Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : [],
}) })
}) })
@@ -322,10 +355,7 @@ export function register(app) {
app.post('/api/study-auth/totp-disable', studyAuthRateLimiter, requireStudyAuth, (req, res) => { app.post('/api/study-auth/totp-disable', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
const user = req.studyUser const user = req.studyUser
const { password } = req.body ?? {} const { password } = req.body ?? {}
const submittedHash = hashStudyPassword(typeof password === 'string' ? password : '') if (!verifyStudyPassword(typeof password === 'string' ? password : '', user.passwordHash)) {
const a = Buffer.from(submittedHash, 'utf8')
const b = Buffer.from(user.passwordHash, 'utf8')
if (a.length !== b.length || !timingSafeEqual(a, b)) {
res.status(401).json({ message: 'Incorrect password.' }) res.status(401).json({ message: 'Incorrect password.' })
return return
} }
@@ -357,11 +387,145 @@ export function register(app) {
const token = cookies[STUDY_SESSION_COOKIE] const token = cookies[STUDY_SESSION_COOKIE]
if (token) { if (token) {
state.studySessions.delete(token) state.studySessions.delete(token)
queueStudySessionsWrite()
} }
clearStudySessionCookie(res) clearStudySessionCookie(res)
res.json({ ok: true }) res.json({ ok: true })
}) })
// Signs out every other active session for the current user (this device stays signed in).
app.post('/api/study-auth/logout-all', requireStudyAuth, (req, res) => {
const cookies = parseCookies(req.headers.cookie)
const currentToken = cookies[STUDY_SESSION_COOKIE]
invalidateAllStudySessions(req.studyUser.id, currentToken)
res.json({ ok: true })
})
// Request a password-reset email. Always responds ok to avoid leaking which emails have accounts.
app.post('/api/study-auth/forgot-password', studyAuthRateLimiter, async (req, res) => {
const username = normalizeStudyUsername(req.body?.username)
if (!isValidStudyUsername(username)) {
res.status(400).json({ message: 'Please enter a valid email address.' })
return
}
const user = findStudyUserByUsername(username)
if (user) {
const rawToken = randomUUID()
user.pendingPasswordReset = {
tokenHash: hashPasswordResetToken(rawToken),
expiresAt: Date.now() + PASSWORD_RESET_TOKEN_TTL_MS,
}
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
const resetUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/reset-password?token=${encodeURIComponent(rawToken)}`)
sendStudyPasswordResetEmail(user.username, user.displayName || user.username, resetUrl)
.catch(err => console.error('[study-forgot-password] send error:', err))
}
res.json({ ok: true, message: 'If that email has an account, a reset link is on its way.' })
})
app.post('/api/study-auth/reset-password', studyAuthRateLimiter, (req, res) => {
const token = typeof req.body?.token === 'string' ? req.body.token.trim() : ''
const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : ''
if (!token) {
res.status(400).json({ message: 'Invalid or missing reset token.' })
return
}
if (newPassword.length < 8 || newPassword.length > 200) {
res.status(400).json({ message: 'New password must be 8–200 characters.' })
return
}
const tokenHash = hashPasswordResetToken(token)
const user = state.studyUsers.find(u => {
const pending = u.pendingPasswordReset
if (!pending?.tokenHash) return false
const a = Buffer.from(tokenHash, 'utf8')
const b = Buffer.from(pending.tokenHash, 'utf8')
return a.length === b.length && timingSafeEqual(a, b)
})
if (!user) {
res.status(400).json({ message: 'This reset link is invalid. Please request a new one.' })
return
}
if (user.pendingPasswordReset.expiresAt <= Date.now()) {
user.pendingPasswordReset = null
queueStudyUsersWrite()
res.status(400).json({ message: 'This reset link has expired. Please request a new one.' })
return
}
user.passwordHash = hashStudyPassword(newPassword)
user.pendingPasswordReset = null
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
invalidateAllStudySessions(user.id)
sendStudyPasswordChangedEmail(user.username, user.displayName || user.username)
.catch(err => console.error('[study-reset-password] confirmation email error:', err))
res.json({ ok: true })
})
// Verify the email address used at signup. Not auth-gated so it works from any device/browser.
app.post('/api/study-auth/verify-email', studyAuthRateLimiter, (req, res) => {
const token = typeof req.body?.token === 'string' ? req.body.token.trim() : ''
if (!token) {
res.status(400).json({ message: 'Invalid or missing verification token.' })
return
}
const tokenHash = hashEmailVerifyToken(token)
const user = state.studyUsers.find(u => {
const pending = u.pendingEmailVerification
if (!pending?.tokenHash) return false
const a = Buffer.from(tokenHash, 'utf8')
const b = Buffer.from(pending.tokenHash, 'utf8')
return a.length === b.length && timingSafeEqual(a, b)
})
if (!user) {
res.status(400).json({ message: 'This verification link is invalid or has already been used.' })
return
}
if (user.pendingEmailVerification.expiresAt <= Date.now()) {
res.status(400).json({ message: 'This verification link has expired. Request a new one from My Account.' })
return
}
user.emailVerified = true
user.pendingEmailVerification = null
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
res.json({ ok: true })
})
app.post('/api/study-auth/resend-verification', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
const user = req.studyUser
if (user.emailVerified) {
res.json({ ok: true, alreadyVerified: true })
return
}
const rawToken = randomUUID()
user.pendingEmailVerification = {
tokenHash: hashEmailVerifyToken(rawToken),
expiresAt: Date.now() + EMAIL_VERIFY_TOKEN_TTL_MS,
}
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
const verifyUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/verify-email?token=${encodeURIComponent(rawToken)}`)
sendStudyVerifyEmail(user.username, user.displayName || user.username, verifyUrl)
.catch(err => console.error('[study-resend-verification] send error:', err))
res.json({ ok: true })
})
// One-click unsubscribe from re-engagement emails (no login required) // One-click unsubscribe from re-engagement emails (no login required)
app.get('/api/study-auth/unsubscribe-reminders', (req, res) => { app.get('/api/study-auth/unsubscribe-reminders', (req, res) => {
const { uid, token } = req.query ?? {} const { uid, token } = req.query ?? {}
+10 -16
View File
@@ -1,9 +1,11 @@
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import rateLimit from 'express-rate-limit' import rateLimit from 'express-rate-limit'
import { requireAdminAuth } from '../auth.js' import { requireAdminAuth } from '../auth.js'
import { requireStudyAuth, normalizeStudySlug, isStudyUserEnrolled, getStudyTitleBySlug } from '../study-helpers.js' import { requireStudyAuth, normalizeStudySlug, hasStudyAccess, getStudyTitleBySlug, getRequiredSectionIds, markStudyCompletedIfDone } from '../study-helpers.js'
import { state } from '../state.js' import { state } from '../state.js'
import { loadUserProgress, queueStudyCertificatesWrite } from '../data.js' import { loadUserProgress, queueStudyCertificatesWrite } from '../data.js'
import { sendStudyCertificateEmail, getCanonicalBaseUrl } from '../email.js'
import { buildAbsoluteUrl } from '../helpers.js'
const publicCertRateLimiter = rateLimit({ const publicCertRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
@@ -13,19 +15,6 @@ const publicCertRateLimiter = rateLimit({
message: { message: 'Too many certificate lookups. Please wait a moment.' }, message: { message: 'Too many certificate lookups. Please wait a moment.' },
}) })
/**
* Returns the list of required section IDs for a study (only released sections).
*/
function getRequiredSectionIds(studySlug) {
const content = state.cachedSiteContent
if (!content || !Array.isArray(content.studies)) return null
const study = content.studies.find(s => s?.slug === studySlug)
if (!study || !Array.isArray(study.sections)) return null
return study.sections
.filter(s => s?.status !== 'unreleased' && s?.id)
.map(s => s.id)
}
export function register(app) { export function register(app) {
// GET — check eligibility and return existing certificate token if one exists // GET — check eligibility and return existing certificate token if one exists
app.get('/api/study-certificate/:studySlug', requireStudyAuth, async (req, res) => { app.get('/api/study-certificate/:studySlug', requireStudyAuth, async (req, res) => {
@@ -33,7 +22,7 @@ export function register(app) {
const studySlug = normalizeStudySlug(req.params.studySlug) const studySlug = normalizeStudySlug(req.params.studySlug)
if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return } if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return }
if (!isStudyUserEnrolled(user, studySlug)) { if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Not enrolled in this study.' }); return res.status(403).json({ message: 'Not enrolled in this study.' }); return
} }
@@ -46,6 +35,8 @@ export function register(app) {
const completedIds = progress.byStudy[studySlug]?.completedSectionIds ?? [] const completedIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
const eligible = requiredIds.every(id => completedIds.includes(id)) const eligible = requiredIds.every(id => completedIds.includes(id))
if (eligible) markStudyCompletedIfDone(user, studySlug, completedIds)
const existing = state.studyCertificates.find(c => c.userId === user.id && c.studySlug === studySlug) const existing = state.studyCertificates.find(c => c.userId === user.id && c.studySlug === studySlug)
res.json({ res.json({
@@ -64,7 +55,7 @@ export function register(app) {
const studySlug = normalizeStudySlug(req.params.studySlug) const studySlug = normalizeStudySlug(req.params.studySlug)
if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return } if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return }
if (!isStudyUserEnrolled(user, studySlug)) { if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Not enrolled in this study.' }); return res.status(403).json({ message: 'Not enrolled in this study.' }); return
} }
@@ -95,6 +86,9 @@ export function register(app) {
} }
state.studyCertificates.push(cert) state.studyCertificates.push(cert)
queueStudyCertificatesWrite() queueStudyCertificatesWrite()
const certUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/certificate/${cert.token}`)
sendStudyCertificateEmail(user.username, user.displayName || user.username, cert.studyTitle, certUrl)
.catch(err => console.error('[study-certificate] email exception:', err))
} }
res.json({ ok: true, token: cert.token, issuedAt: cert.issuedAt }) res.json({ ok: true, token: cert.token, issuedAt: cert.issuedAt })
+20 -9
View File
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import { requireAdminAuth } from '../auth.js' import { requireAdminAuth } from '../auth.js'
import { requireStudyAuth, normalizeStudySlug, normalizeLessonSectionId, isStudyUserEnrolled } from '../study-helpers.js' import { requireStudyAuth, normalizeStudySlug, normalizeLessonSectionId, hasStudyAccess, findStudyUserById, getCommunityDisplayName, isStudyDiscussionOpen, DISCUSSION_CLOSED_MESSAGE } from '../study-helpers.js'
import { state } from '../state.js' import { state } from '../state.js'
import { queueStudyCommentsWrite } from '../data.js' import { queueStudyCommentsWrite } from '../data.js'
import { MAX_STUDY_COMMENTS, MAX_STUDY_COMMENT_LENGTH } from '../config.js' import { MAX_STUDY_COMMENTS, MAX_STUDY_COMMENT_LENGTH } from '../config.js'
@@ -14,15 +14,22 @@ export function register(app) {
res.status(400).json({ message: 'Invalid study slug or section id.' }); return res.status(400).json({ message: 'Invalid study slug or section id.' }); return
} }
if (!isStudyDiscussionOpen(studySlug)) {
res.json({ comments: [], closed: true, message: DISCUSSION_CLOSED_MESSAGE }); return
}
const comments = state.studyComments const comments = state.studyComments
.filter(c => c.studySlug === studySlug && c.sectionId === sectionId && c.isApproved === true) .filter(c => c.studySlug === studySlug && c.sectionId === sectionId && c.isApproved === true)
.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)) .sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
.map(c => ({ .map(c => {
id: c.id, const author = findStudyUserById(c.userId)
displayName: c.displayName, return {
text: c.text, id: c.id,
createdAt: c.createdAt, displayName: author ? getCommunityDisplayName(author) : c.displayName,
})) text: c.text,
createdAt: c.createdAt,
}
})
res.json({ comments }) res.json({ comments })
}) })
@@ -37,10 +44,14 @@ export function register(app) {
res.status(400).json({ message: 'Invalid study slug or section id.' }); return res.status(400).json({ message: 'Invalid study slug or section id.' }); return
} }
if (!isStudyUserEnrolled(user, studySlug)) { if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to leave a comment.' }); return res.status(403).json({ message: 'Please enroll in this study to leave a comment.' }); return
} }
if (!isStudyDiscussionOpen(studySlug)) {
res.status(403).json({ message: DISCUSSION_CLOSED_MESSAGE, closed: true }); return
}
const text = typeof req.body?.text === 'string' ? req.body.text.trim() : '' const text = typeof req.body?.text === 'string' ? req.body.text.trim() : ''
if (!text || text.length < 2 || text.length > MAX_STUDY_COMMENT_LENGTH) { if (!text || text.length < 2 || text.length > MAX_STUDY_COMMENT_LENGTH) {
res.status(400).json({ message: `Comment must be between 2 and ${MAX_STUDY_COMMENT_LENGTH} characters.` }); return res.status(400).json({ message: `Comment must be between 2 and ${MAX_STUDY_COMMENT_LENGTH} characters.` }); return
@@ -53,7 +64,7 @@ export function register(app) {
studySlug, studySlug,
sectionId, sectionId,
userId: user.id, userId: user.id,
displayName: user.displayName || user.username, displayName: getCommunityDisplayName(user),
text, text,
createdAt: now, createdAt: now,
isApproved: true, isApproved: true,
+121 -19
View File
@@ -3,12 +3,17 @@ import {
normalizeStudySlug, normalizeStudySlug,
normalizeLessonSectionId, normalizeLessonSectionId,
getStudySlugFromNoteId, getStudySlugFromNoteId,
isStudyUserEnrolled, hasStudyAccess,
isEnrollableStudySlug, isEnrollableStudySlug,
getStudyTitleBySlug, getStudyTitleBySlug,
findStudyUserById, findStudyUserById,
getStudyAvatarUrl, getStudyAvatarUrl,
getStudyCatalog, getStudyCatalog,
getCommunityDisplayName,
getAccessibleStudySlugs,
markStudyCompletedIfDone,
isStudyDiscussionOpen,
DISCUSSION_CLOSED_MESSAGE,
} from '../study-helpers.js' } from '../study-helpers.js'
import { state } from '../state.js' import { state } from '../state.js'
import { import {
@@ -58,6 +63,11 @@ export function register(app) {
const enrolled = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [] const enrolled = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
if (!enrolled.includes(studySlug)) { if (!enrolled.includes(studySlug)) {
const catalogEntry = getStudyCatalog().find(study => study.slug === studySlug)
if (catalogEntry?.status === 'planned') {
res.status(400).json({ message: 'This study is not open for enrollment yet.' })
return
}
user.enrolledStudySlugs = [...enrolled, studySlug].slice(0, MAX_STUDY_ENROLLMENTS_PER_USER) user.enrolledStudySlugs = [...enrolled, studySlug].slice(0, MAX_STUDY_ENROLLMENTS_PER_USER)
user.updatedAt = new Date().toISOString() user.updatedAt = new Date().toISOString()
queueStudyUsersWrite() queueStudyUsersWrite()
@@ -67,7 +77,8 @@ export function register(app) {
ok: true, ok: true,
studySlug, studySlug,
studyTitle: getStudyTitleBySlug(studySlug) || studySlug, studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
enrolledStudySlugs: user.enrolledStudySlugs, enrolledStudySlugs: getAccessibleStudySlugs(user),
completedStudySlugs: Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : [],
}) })
}) })
@@ -90,7 +101,8 @@ export function register(app) {
ok: true, ok: true,
studySlug, studySlug,
studyTitle: getStudyTitleBySlug(studySlug) || studySlug, studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
enrolledStudySlugs: user.enrolledStudySlugs, enrolledStudySlugs: getAccessibleStudySlugs(user),
completedStudySlugs: Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : [],
}) })
}) })
@@ -110,7 +122,7 @@ export function register(app) {
} }
const user = req.studyUser const user = req.studyUser
const noteStudySlug = getStudySlugFromNoteId(sectionId) const noteStudySlug = getStudySlugFromNoteId(sectionId)
if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) { if (noteStudySlug && !hasStudyAccess(user, noteStudySlug)) {
res.status(403).json({ message: 'Please enroll in this study to access notes.' }) res.status(403).json({ message: 'Please enroll in this study to access notes.' })
return return
} }
@@ -130,12 +142,16 @@ export function register(app) {
return return
} }
const noteStudySlug = getStudySlugFromNoteId(sectionId) const noteStudySlug = getStudySlugFromNoteId(sectionId)
if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) { if (noteStudySlug && !hasStudyAccess(user, noteStudySlug)) {
res.status(403).json({ message: 'Please enroll in this study to save notes.' }) res.status(403).json({ message: 'Please enroll in this study to save notes.' })
return 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()
if (note.length > MAX_STUDY_NOTE_LENGTH) {
res.status(413).json({ message: 'This lesson\'s notes are too long to save. Please trim some text.' })
return
}
const notes = await loadUserNotes(user.id) const notes = await loadUserNotes(user.id)
if (!note) { if (!note) {
@@ -164,13 +180,14 @@ export function register(app) {
return return
} }
if (!isStudyUserEnrolled(user, studySlug)) { if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to view progress.' }) res.status(403).json({ message: 'Please enroll in this study to view progress.' })
return return
} }
const progress = await loadUserProgress(user.id) const progress = await loadUserProgress(user.id)
const completedSectionIds = progress.byStudy[studySlug]?.completedSectionIds ?? [] const completedSectionIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
const bookmarkedSectionIds = progress.byStudy[studySlug]?.bookmarkedSectionIds ?? []
// Record first visit timestamp on the user record (additive, never overwrite) // Record first visit timestamp on the user record (additive, never overwrite)
if (!user.firstVisitAt) { if (!user.firstVisitAt) {
@@ -178,7 +195,7 @@ export function register(app) {
queueStudyUsersWrite() queueStudyUsersWrite()
} }
res.json({ studySlug, completedSectionIds }) res.json({ studySlug, completedSectionIds, bookmarkedSectionIds })
}) })
app.post('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => { app.post('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
@@ -190,7 +207,7 @@ export function register(app) {
return return
} }
if (!isStudyUserEnrolled(user, studySlug)) { if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to update progress.' }) res.status(403).json({ message: 'Please enroll in this study to update progress.' })
return return
} }
@@ -220,8 +237,9 @@ export function register(app) {
user.firstCompletionAt = new Date().toISOString() user.firstCompletionAt = new Date().toISOString()
} }
queueStudyUsersWrite() queueStudyUsersWrite()
const justCompleted = markStudyCompletedIfDone(user, studySlug, studyProgress.completedSectionIds)
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds }) res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds, studyCompleted: justCompleted || undefined })
}) })
app.delete('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => { app.delete('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
@@ -233,7 +251,7 @@ export function register(app) {
return return
} }
if (!isStudyUserEnrolled(user, studySlug)) { if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to update progress.' }) res.status(403).json({ message: 'Please enroll in this study to update progress.' })
return return
} }
@@ -260,7 +278,7 @@ export function register(app) {
return return
} }
if (!isStudyUserEnrolled(user, studySlug)) { if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to view quiz answers.' }) res.status(403).json({ message: 'Please enroll in this study to view quiz answers.' })
return return
} }
@@ -279,7 +297,7 @@ export function register(app) {
return return
} }
if (!isStudyUserEnrolled(user, studySlug)) { if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to save quiz answers.' }) res.status(403).json({ message: 'Please enroll in this study to save quiz answers.' })
return return
} }
@@ -301,6 +319,45 @@ export function register(app) {
res.json({ ok: true, studySlug, sectionId, answers }) res.json({ ok: true, studySlug, sectionId, answers })
}) })
// ── Bookmarks ─────────────────────────────────────────────────────────────
app.post('/api/study-progress/:studySlug/:sectionId/bookmark', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
const sectionId = normalizeLessonSectionId(req.params.sectionId)
if (!studySlug || !sectionId) { res.status(400).json({ message: 'Invalid study slug or section id.' }); return }
if (!hasStudyAccess(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to bookmark lessons.' }); return }
const progress = await loadUserProgress(user.id)
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [], bookmarkedSectionIds: [] }
const bookmarkedSectionIds = Array.isArray(studyProgress.bookmarkedSectionIds) ? studyProgress.bookmarkedSectionIds : []
if (!bookmarkedSectionIds.includes(sectionId)) {
studyProgress.bookmarkedSectionIds = [...bookmarkedSectionIds, sectionId]
}
progress.byStudy[studySlug] = studyProgress
progress.updatedAt = new Date().toISOString()
state.studyProgressCache.set(user.id, progress)
queueUserProgressWrite(user.id)
res.json({ ok: true, studySlug, bookmarkedSectionIds: studyProgress.bookmarkedSectionIds })
})
app.delete('/api/study-progress/:studySlug/:sectionId/bookmark', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
const sectionId = normalizeLessonSectionId(req.params.sectionId)
if (!studySlug || !sectionId) { res.status(400).json({ message: 'Invalid study slug or section id.' }); return }
if (!hasStudyAccess(user, studySlug)) { res.status(403).json({ message: 'Not enrolled.' }); return }
const progress = await loadUserProgress(user.id)
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [], bookmarkedSectionIds: [] }
studyProgress.bookmarkedSectionIds = (studyProgress.bookmarkedSectionIds ?? []).filter(id => id !== sectionId)
progress.byStudy[studySlug] = studyProgress
progress.updatedAt = new Date().toISOString()
state.studyProgressCache.set(user.id, progress)
queueUserProgressWrite(user.id)
res.json({ ok: true, studySlug, bookmarkedSectionIds: studyProgress.bookmarkedSectionIds })
})
// ── Community ───────────────────────────────────────────────────────────── // ── Community ─────────────────────────────────────────────────────────────
app.get('/api/study-community', requireStudyAuth, async (req, res) => { app.get('/api/study-community', requireStudyAuth, async (req, res) => {
@@ -312,24 +369,37 @@ export function register(app) {
return return
} }
if (!isStudyUserEnrolled(user, studySlug)) { if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to view the community.' }) res.status(403).json({ message: 'Please enroll in this study to view the community.' })
return return
} }
if (!isStudyDiscussionOpen(studySlug)) {
res.json({ studySlug, posts: [], closed: true, message: DISCUSSION_CLOSED_MESSAGE })
return
}
const posts = state.studyCommunityPosts const posts = state.studyCommunityPosts
.filter(post => post.studySlug === studySlug) .filter(post => post.studySlug === studySlug)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 50) .slice(0, 50)
.map(post => { .map(post => {
const author = findStudyUserById(post.authorUserId) const author = findStudyUserById(post.authorUserId)
const likedBy = Array.isArray(post.likedBy) ? post.likedBy : []
return { return {
...post, ...post,
authorName: author ? getCommunityDisplayName(author) : post.authorName,
authorAvatarUrl: getStudyAvatarUrl(author || post.authorName || ''), authorAvatarUrl: getStudyAvatarUrl(author || post.authorName || ''),
likedByMe: likedBy.includes(user.id),
likeCount: likedBy.length,
replies: Array.isArray(post.replies) replies: Array.isArray(post.replies)
? post.replies.map(reply => { ? post.replies.map(reply => {
const replyAuthor = findStudyUserById(reply.authorUserId) const replyAuthor = findStudyUserById(reply.authorUserId)
return { ...reply, authorAvatarUrl: getStudyAvatarUrl(replyAuthor || reply.authorName || '') } return {
...reply,
authorName: replyAuthor ? getCommunityDisplayName(replyAuthor) : reply.authorName,
authorAvatarUrl: getStudyAvatarUrl(replyAuthor || reply.authorName || ''),
}
}) })
: [], : [],
} }
@@ -349,13 +419,18 @@ export function register(app) {
return return
} }
if (!isStudyUserEnrolled(user, studySlug)) { if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to post in the community.' }) res.status(403).json({ message: 'Please enroll in this study to post in the community.' })
return return
} }
if (!isStudyDiscussionOpen(studySlug)) {
res.status(403).json({ message: DISCUSSION_CLOSED_MESSAGE, closed: true })
return
}
const now = new Date().toISOString() const now = new Date().toISOString()
const authorName = user.displayName?.trim() || (user.username?.includes('@') ? user.username.split('@')[0] : user.username) const authorName = getCommunityDisplayName(user)
const post = { const post = {
id: randomUUID(), id: randomUUID(),
studySlug, studySlug,
@@ -391,15 +466,20 @@ export function register(app) {
return return
} }
if (!isStudyUserEnrolled(user, post.studySlug)) { if (!hasStudyAccess(user, post.studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to reply in the community.' }) res.status(403).json({ message: 'Please enroll in this study to reply in the community.' })
return return
} }
if (!isStudyDiscussionOpen(post.studySlug)) {
res.status(403).json({ message: DISCUSSION_CLOSED_MESSAGE, closed: true })
return
}
const reply = { const reply = {
id: randomUUID(), id: randomUUID(),
authorUserId: user.id, authorUserId: user.id,
authorName: user.displayName?.trim() || (user.username?.includes('@') ? user.username.split('@')[0] : user.username), authorName: getCommunityDisplayName(user),
authorAvatarUrl: getStudyAvatarUrl(user.username), authorAvatarUrl: getStudyAvatarUrl(user.username),
message, message,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@@ -412,4 +492,26 @@ export function register(app) {
res.json({ ok: true, reply }) res.json({ ok: true, reply })
}) })
app.post('/api/study-community/posts/:postId/like', requireStudyAuth, (req, res) => {
const user = req.studyUser
const postId = typeof req.params.postId === 'string' ? req.params.postId.trim() : ''
const post = state.studyCommunityPosts.find(item => item.id === postId)
if (!post) { res.status(404).json({ message: 'Post not found.' }); return }
if (!hasStudyAccess(user, post.studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to like posts.' }); return
}
if (!isStudyDiscussionOpen(post.studySlug)) {
res.status(403).json({ message: DISCUSSION_CLOSED_MESSAGE, closed: true }); return
}
if (!Array.isArray(post.likedBy)) post.likedBy = []
const idx = post.likedBy.indexOf(user.id)
if (idx === -1) {
post.likedBy.push(user.id)
} else {
post.likedBy.splice(idx, 1)
}
queueStudyCommunityWrite()
res.json({ ok: true, likeCount: post.likedBy.length, likedByMe: post.likedBy.includes(user.id) })
})
} }
+50
View File
@@ -0,0 +1,50 @@
// Shared podcast-RSS parsing, used by both the main show (episodes.js) and
// any sub-show fed by its own feed URL (nuggets.js).
export function extractCdata(raw) {
const cdata = /^<!\[CDATA\[([\s\S]*?)\]\]>$/.exec(raw.trim())
return cdata ? cdata[1].trim() : raw.trim()
}
function decodeEntities(str) {
return str
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#39;/g, "'")
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCharCode(parseInt(h, 16)))
.replace(/&nbsp;/g, ' ')
}
export function parseRssItems(xml, limit = Infinity) {
const items = []
const itemRegex = /<item>([\s\S]*?)<\/item>/g
let match
while ((match = itemRegex.exec(xml)) !== null && items.length < limit) {
const block = match[1]
const titleRaw = /<title>([\s\S]*?)<\/title>/.exec(block)?.[1] ?? ''
const title = extractCdata(titleRaw)
if (!title) continue
const pubDate = (/<pubDate>([\s\S]*?)<\/pubDate>/.exec(block)?.[1] ?? '').trim()
const guidRaw = /<guid[^>]*>([\s\S]*?)<\/guid>/.exec(block)?.[1] ?? ''
const guid = extractCdata(guidRaw)
const enclosureUrl = /<enclosure[^>]+url="([^"]+)"/.exec(block)?.[1] ?? ''
const link = guid.startsWith('http') ? guid : enclosureUrl
const descRaw = /<description>([\s\S]*?)<\/description>/.exec(block)?.[1] ?? ''
const descText = decodeEntities(extractCdata(descRaw).replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim()
const duration = (/<itunes:duration>([\s\S]*?)<\/itunes:duration>/.exec(block)?.[1] ?? '').trim()
const episode = (/<itunes:episode>([\s\S]*?)<\/itunes:episode>/.exec(block)?.[1] ?? '').trim()
const season = (/<itunes:season>([\s\S]*?)<\/itunes:season>/.exec(block)?.[1] ?? '').trim()
items.push({
title, pubDate, link,
audioUrl: enclosureUrl,
description: descText.slice(0, 800) + (descText.length > 800 ? '…' : ''),
duration, episode, season,
})
}
return items
}
+30
View File
@@ -0,0 +1,30 @@
import dns from 'node:dns'
import { SEAFILE_URL } from './config.js'
// Alpine's resolver (musl) fails the whole lookup when a DNS server answers the IPv6 (AAAA)
// query with "no such name" even though the IPv4 answer is fine, so Node reports ENOTFOUND for
// a host that resolves. Ask for IPv4 only, and only for the Seafile host.
export function preferIpv4ForSeafile() {
let host = ''
try {
host = SEAFILE_URL ? new URL(SEAFILE_URL).hostname : ''
} catch {
return
}
if (!host) return
const originalLookup = dns.lookup
dns.lookup = function lookup(hostname, options, callback) {
if (hostname === host) {
if (typeof options === 'function') {
callback = options
options = {}
} else if (typeof options === 'number') {
options = { family: options }
}
if (!options?.family) options = { ...options, family: 4 }
}
return originalLookup.call(this, hostname, options, callback)
}
console.log(`[seafile-dns] resolving ${host} over IPv4 only`)
}
+174
View File
@@ -0,0 +1,174 @@
import { state } from './state.js'
import { requireAdminAuth } from './auth.js'
import { scriptKey } from './episode-script-keys.js'
import { getFeedIndex } from './feed-release.js'
import { loadPodcastWorkflow, laneSeasonOf } from './routes/podcast-workflow.js'
// One report per lane (A = primary series, B = second series) listing what is set up, what is
// missing, and an episode-by-episode grid. Reads the working draft so problems show up before
// publishing. Read-only: it never changes anything.
const err = (message, view) => ({ severity: 'error', message, view })
const warn = (message, view) => ({ severity: 'warn', message, view })
function laneConfig(content, lane) {
if (lane === 'A') {
return {
title: String(content.seriesTitle ?? '').trim(),
studySlug: String(content.seriesStudySlug ?? '').trim(),
highlightLane: 'primary',
description: String(content.seriesDescription ?? '').trim(),
imageUrl: String(content.seriesImageUrl ?? '').trim(),
listenUrl: String(content.seriesListenUrl ?? '').trim(),
hasGuide: Boolean(String(content.studyGuideTitle ?? '').trim() && (content.studyGuideDownloadUrl || content.studyGuideUrl || content.studyGuideAppleBooksUrl)),
transition: content.scheduledSeriesTransition,
}
}
const second = content.secondSeries ?? {}
return {
title: String(second.title ?? '').trim(),
studySlug: String(second.studySlug ?? '').trim(),
highlightLane: 'second',
description: String(second.description ?? '').trim(),
imageUrl: String(second.imageUrl ?? '').trim(),
listenUrl: String(second.listenUrl ?? '').trim(),
hasGuide: Boolean(String(second.guide?.title ?? '').trim() && (second.guide?.downloadUrl || second.guide?.amazonUrl || second.guide?.appleBooksUrl)),
transition: content.scheduledSecondSeriesTransition,
}
}
export async function buildSeasonCheck() {
const content = state.cachedDraftSiteContent ?? state.cachedSiteContent ?? {}
const [wf, index] = await Promise.all([loadPodcastWorkflow(), getFeedIndex()])
const seasons = { A: laneSeasonOf('A', wf, content), B: laneSeasonOf('B', wf, content) }
const studies = Array.isArray(content.studies) ? content.studies : []
const highlights = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
const planned = new Set(wf.planned.map(p => scriptKey(p.season, p.episodeNumber)))
const lanes = []
for (const lane of ['A', 'B']) {
const cfg = laneConfig(content, lane)
// Lane B only exists once a second series has been set up
if (lane === 'B' && !cfg.title && !cfg.studySlug && !seasons.B) continue
const season = seasons[lane]
const problems = []
if (!cfg.title) problems.push(warn(`Lane ${lane} has no series title.`, 'podcast:current-series'))
if (!season) problems.push(err(`Lane ${lane} has no season number. Highlights and lesson audio can't match the feed without it.`, 'podcast:workflow'))
if (season && lane === 'B' && season === seasons.A) problems.push(err(`Lanes A and B both use season ${season}.`, 'podcast:workflow'))
const study = studies.find(s => s?.lane === lane) ?? studies.find(s => s?.slug && s.slug === cfg.studySlug)
if (!study) problems.push(warn(`No study is linked to Lane ${lane}. Set the study's lane, or link it in Current Series.`, 'colossians-study'))
else if (study.lane !== lane) problems.push(warn(`"${study.title}" is linked to Lane ${lane} in Current Series but the study's own lane isn't set to ${lane}.`, 'colossians-study'))
const sections = Array.isArray(study?.sections) ? study.sections : []
const laneHighlights = highlights.filter(h => (h.lane ?? 'primary') === cfg.highlightLane)
const noNumber = sections.filter(s => !String(s.episodeNumber ?? '').trim())
if (study && noNumber.length > 0) {
problems.push(warn(`${noNumber.length} lesson${noNumber.length === 1 ? '' : 's'} in "${study.title}" ${noNumber.length === 1 ? 'has' : 'have'} no episode number, so ${noNumber.length === 1 ? 'it' : 'they'} can't pick up audio.`, 'colossians-study'))
}
const noDate = sections.filter(s => String(s.episodeNumber ?? '').trim() && !s.releasedAt)
if (study && noDate.length > 0) {
problems.push(warn(`${noDate.length} lesson${noDate.length === 1 ? '' : 's'} with an episode number ${noDate.length === 1 ? 'has' : 'have'} no release date.`, 'colossians-study'))
}
const noHighlightNumber = laneHighlights.filter(h => !String(h.episodeNumber ?? '').trim())
if (noHighlightNumber.length > 0) {
problems.push(warn(`${noHighlightNumber.length} highlight${noHighlightNumber.length === 1 ? '' : 's'} in Lane ${lane} ${noHighlightNumber.length === 1 ? 'has' : 'have'} no episode number.`, 'podcast:highlights'))
}
// Series card
if (!cfg.imageUrl) problems.push(warn(`Lane ${lane} has no cover image.`, 'podcast:current-series'))
if (!cfg.description) problems.push(warn(`Lane ${lane} has no series description.`, 'podcast:current-series'))
if (!cfg.listenUrl) problems.push(warn(`Lane ${lane} has no listen link.`, 'podcast:current-series'))
if (!cfg.hasGuide) problems.push(warn(`Lane ${lane} has no companion study guide (title plus a download or purchase link).`, 'podcast:current-series'))
// Handover to the next season
const t = cfg.transition
if (t) {
if (!String(t.newTitle ?? '').trim() || !t.newSeason) {
problems.push(warn(`The next-season handover for Lane ${lane} is scheduled but is missing its title or season number.`, 'podcast:current-series'))
} else if (season && String(t.newSeason) === season) {
problems.push(warn(`The next-season handover for Lane ${lane} uses the same season number (${season}) as the current one.`, 'podcast:current-series'))
}
} else if (study && sections.length > 0 && sections.every(x => x.releasedAt && new Date(x.releasedAt) <= new Date())) {
problems.push(warn(`Every lesson in "${study.title}" is released, but no next season is scheduled for Lane ${lane}.`, 'podcast:current-series'))
}
// Episode numbers to show: every number any part of the season mentions, plus 1..lesson count
const numbers = new Set()
for (let n = 1; n <= sections.length; n++) numbers.add(n)
const addNumber = v => { const n = Number(v); if (Number.isInteger(n) && n > 0) numbers.add(n) }
sections.forEach(s => addNumber(s.episodeNumber))
laneHighlights.forEach(h => addNumber(h.episodeNumber))
for (const [key, item] of index) if (season && key.startsWith(`${season}-`)) addNumber(item.episode)
for (const p of wf.planned) if (season && String(p.season) === season) addNumber(p.episodeNumber)
for (const key of Object.keys(state.episodeScripts ?? {})) if (season && key.startsWith(`${season}-`)) addNumber(key.split('-')[1])
const episodes = [...numbers].sort((a, b) => a - b).map(n => {
const num = String(n)
const feedItem = season ? index.get(`${season}-${num}`) : undefined
const released = Boolean(feedItem)
const lessons = sections.filter(s => String(s.episodeNumber ?? '').trim() === num)
const hl = laneHighlights.filter(h => String(h.episodeNumber ?? '').trim() === num)
const lessonHasAudio = lessons.some(s => s.audioEmbedUrl?.trim() || (released && feedItem?.audioUrl))
if (lessons.length > 1) problems.push(warn(`Episode ${num}: ${lessons.length} lessons share this episode number.`, 'colossians-study'))
if (hl.length > 1) problems.push(warn(`Episode ${num}: ${hl.length} highlights share this episode number.`, 'podcast:highlights'))
if (season && !released && hl.some(h => !h.holdUntilReleased && h.lane !== 'general')) {
problems.push(warn(`Episode ${num}: the highlight will show on the site before the episode is out. Turn on "Publish when the episode is released".`, 'podcast:highlights'))
}
return {
number: n,
released,
releaseDate: feedItem?.pubDate ? new Date(feedItem.pubDate).toISOString().slice(0, 10) : '',
workflowStarted: season ? planned.has(scriptKey(season, num)) : false,
script: season ? Boolean(state.episodeScripts?.[scriptKey(season, num)]) : false,
lesson: lessons.length > 0,
lessonAudio: lessons.length > 0 ? lessonHasAudio : null,
highlight: hl.length > 0,
highlightHeld: hl.length > 0 ? hl.every(h => h.holdUntilReleased === true) : null,
}
})
lanes.push({
lane,
title: cfg.title,
season,
studyTitle: study?.title ?? '',
problems,
errors: problems.filter(p => p.severity === 'error').length,
warnings: problems.filter(p => p.severity === 'warn').length,
episodes,
})
}
// Scripts are filed by season + episode, so one with no season can't be matched to an episode
const general = []
const unseasoned = Object.entries(state.episodeScripts ?? {}).filter(([, entry]) => !String(entry?.season ?? '').trim())
if (unseasoned.length > 0) {
general.push(warn(`${unseasoned.length} episode script${unseasoned.length === 1 ? ' has' : 's have'} no season number, so ${unseasoned.length === 1 ? 'it' : 'they'} can't be matched to an episode. Use the season backfill in Episode Scripts.`, 'podcast:scripts'))
}
const all = [...lanes.flatMap(l => l.problems), ...general]
return {
lanes,
general,
total: all.length,
errors: all.filter(p => p.severity === 'error').length,
warnings: all.filter(p => p.severity === 'warn').length,
}
}
export function register(app) {
app.get('/api/admin/season-check', requireAdminAuth, async (_req, res) => {
try {
res.json(await buildSeasonCheck())
} catch (err) {
console.error('[season-check] failed:', err)
res.status(500).json({ message: 'Could not build the season check.' })
}
})
}
+12 -6
View File
@@ -3,10 +3,11 @@ import {
EMPTY_VISITOR_STATS, EMPTY_VISITOR_STATS,
DEFAULT_PUBLISH_STATE, DEFAULT_PUBLISH_STATE,
DEFAULT_REPLY_TEMPLATES, DEFAULT_REPLY_TEMPLATES,
buildDefaultPodcastChecklist,
} from './config.js' } from './config.js'
export const state = { export const state = {
serverStartedAt: new Date().toISOString(),
cachedSiteContent: null, cachedSiteContent: null,
cachedDraftSiteContent: null, cachedDraftSiteContent: null,
publishState: { ...DEFAULT_PUBLISH_STATE }, publishState: { ...DEFAULT_PUBLISH_STATE },
@@ -34,8 +35,6 @@ export const state = {
replyHistory: [], replyHistory: [],
replyHistoryWritePromise: Promise.resolve(), replyHistoryWritePromise: Promise.resolve(),
podcastChecklist: buildDefaultPodcastChecklist(),
podcastChecklistWritePromise: Promise.resolve(),
studyUsers: [], studyUsers: [],
studyUsersWritePromise: Promise.resolve(), studyUsersWritePromise: Promise.resolve(),
@@ -52,7 +51,7 @@ export const state = {
studyCertificates: [], studyCertificates: [],
studyCertificatesWritePromise: Promise.resolve(), studyCertificatesWritePromise: Promise.resolve(),
// episodeScripts: { [episodeNumber]: { text, title, filename, uploadedAt } } // episodeScripts: { ["<season>-<episode>" or "<episode>" if no season]: { episodeNumber, text, title, filename, season, uploadedAt } }
episodeScripts: {}, episodeScripts: {},
episodeScriptsWritePromise: Promise.resolve(), episodeScriptsWritePromise: Promise.resolve(),
@@ -81,6 +80,7 @@ export const state = {
searchQueries: {}, searchQueries: {},
notFound: {}, notFound: {},
audioEvents: {}, audioEvents: {},
linkClicks: {},
}, },
analyticsEventsWritePromise: Promise.resolve(), analyticsEventsWritePromise: Promise.resolve(),
@@ -93,8 +93,13 @@ export const state = {
emailSettings: { signature: 'Grace and peace,\nVerse by Verse with Nate' }, emailSettings: { signature: 'Grace and peace,\nVerse by Verse with Nate' },
emailSettingsWritePromise: Promise.resolve(), emailSettingsWritePromise: Promise.resolve(),
calendarEvents: [], // newsletterIssues: Array<{ id, resendBroadcastId, slug, title, subject, description, html, sentAt, publishedAt, public }>
calendarEventsWritePromise: Promise.resolve(), newsletterIssues: [],
newsletterIssuesWritePromise: Promise.resolve(),
// nuggetsEpisodeTags: { [audioUrl]: string[] } — admin-assigned tags for Nuggets RSS episodes
nuggetsEpisodeTags: {},
nuggetsEpisodeTagsWritePromise: Promise.resolve(),
auditLog: [], auditLog: [],
auditLogWritePromise: Promise.resolve(), auditLogWritePromise: Promise.resolve(),
@@ -113,6 +118,7 @@ export const state = {
studyProgressWriteQueues: new Map(), // userId -> Promise studyProgressWriteQueues: new Map(), // userId -> Promise
studySessions: new Map(), studySessions: new Map(),
studySessionsWritePromise: Promise.resolve(),
studyTotpPendingTokens: new Map(), studyTotpPendingTokens: new Map(),
emailOtpStore: new Map(), emailOtpStore: new Map(),
+314 -88
View File
@@ -1,6 +1,15 @@
import { createHash, createHmac, randomUUID } from 'node:crypto' import { createHash, createHmac, randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'node:crypto'
import path from 'node:path' import path from 'node:path'
import { parseCookies, cookieSecureFlag } from './helpers.js' import {
parseCookies,
cookieSecureFlag,
normalizeIp,
isPrivateOrLocalIp,
normalizeRedirectPath,
sanitizeUrl,
sanitizeRedirectRules,
} from './helpers.js'
export { normalizeIp, isPrivateOrLocalIp, normalizeRedirectPath, sanitizeUrl, sanitizeRedirectRules } from './helpers.js'
import { import {
STUDY_SESSION_COOKIE, STUDY_SESSION_COOKIE,
STUDY_SESSION_TTL_MS, STUDY_SESSION_TTL_MS,
@@ -9,9 +18,11 @@ import {
EMAIL_OTP_MAX_ATTEMPTS, EMAIL_OTP_MAX_ATTEMPTS,
CONTACT_EMAIL_COOLDOWN_MS, CONTACT_EMAIL_COOLDOWN_MS,
DEFAULT_REDIRECT_RULES, DEFAULT_REDIRECT_RULES,
VISITOR_RETENTION_DAYS_DEFAULT,
DOWNLOAD_TOKEN_TTL_MS,
} from './config.js' } from './config.js'
import { state } from './state.js' import { state } from './state.js'
import { queueStudyRemindersWrite, queueStudyUsersWrite } from './data.js' import { queueStudyRemindersWrite, queueStudyUsersWrite, queueStudySessionsWrite, loadUserProgress } from './data.js'
// ── Username / slug normalizers ──────────────────────────────────────────── // ── Username / slug normalizers ────────────────────────────────────────────
@@ -45,16 +56,68 @@ export function getStudySlugFromNoteId(sectionId) {
// ── Password / token hashing ─────────────────────────────────────────────── // ── Password / token hashing ───────────────────────────────────────────────
export function hashStudyPassword(password) { const LEGACY_HASH_RE = /^[0-9a-f]{64}$/
function legacyHashStudyPassword(password) {
return createHash('sha256').update(`study-user:${String(password)}`).digest('hex') return createHash('sha256').update(`study-user:${String(password)}`).digest('hex')
} }
// New hashes are `scrypt$<salt hex>$<hash hex>`. Legacy accounts hold an
// unsalted SHA-256 hex digest; verifyStudyPassword accepts both and
// studyPasswordNeedsRehash lets login upgrade them transparently.
export function hashStudyPassword(password) {
const salt = randomBytes(16)
const hash = scryptSync(String(password), salt, 64)
return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`
}
export function verifyStudyPassword(password, stored) {
if (typeof stored !== 'string' || !stored) return false
let expected
let actual
if (stored.startsWith('scrypt$')) {
const [, saltHex, hashHex] = stored.split('$')
if (!saltHex || !hashHex) return false
expected = Buffer.from(hashHex, 'hex')
actual = scryptSync(String(password), Buffer.from(saltHex, 'hex'), expected.length)
} else if (LEGACY_HASH_RE.test(stored)) {
expected = Buffer.from(stored, 'hex')
actual = Buffer.from(legacyHashStudyPassword(password), 'hex')
} else {
return false
}
return expected.length === actual.length && timingSafeEqual(expected, actual)
}
export function studyPasswordNeedsRehash(stored) {
return typeof stored === 'string' && !stored.startsWith('scrypt$')
}
export function hashEmailChangeToken(token) { export function hashEmailChangeToken(token) {
return createHash('sha256').update(`study-email-change:${String(token)}`).digest('hex') return createHash('sha256').update(`study-email-change:${String(token)}`).digest('hex')
} }
export function hashPasswordResetToken(token) {
return createHash('sha256').update(`study-password-reset:${String(token)}`).digest('hex')
}
export function hashEmailVerifyToken(token) {
return createHash('sha256').update(`study-email-verify:${String(token)}`).digest('hex')
}
// ── Catalog / enrollment helpers ─────────────────────────────────────────── // ── Catalog / enrollment helpers ───────────────────────────────────────────
// A study is 'complete' when an admin marks it so, or when it has lessons and every
// one of them has already been released. Planned studies never auto-complete.
export function getEffectiveStudyStatus(study) {
if (!study || typeof study !== 'object') return 'active'
if (study.status === 'planned') return 'planned'
if (study.status === 'complete') return 'complete'
const sections = Array.isArray(study.sections) ? study.sections : []
if (sections.length > 0 && sections.every(isSectionReleased)) return 'complete'
return 'active'
}
export function getStudyCatalog() { export function getStudyCatalog() {
const fallback = [ const fallback = [
{ slug: 'colossians', title: 'Colossians: Rooted in Christ', status: 'active' }, { slug: 'colossians', title: 'Colossians: Rooted in Christ', status: 'active' },
@@ -72,7 +135,7 @@ export function getStudyCatalog() {
out.push({ out.push({
slug, slug,
title: typeof study?.title === 'string' && study.title.trim() ? study.title.trim() : slug, title: typeof study?.title === 'string' && study.title.trim() ? study.title.trim() : slug,
status: study?.status === 'planned' ? 'planned' : 'active', status: getEffectiveStudyStatus(study),
}) })
} }
if (out.length > 0) return out if (out.length > 0) return out
@@ -100,6 +163,81 @@ export function isStudyUserEnrolled(user, studySlug) {
return Array.isArray(user.enrolledStudySlugs) && user.enrolledStudySlugs.includes(normalized) return Array.isArray(user.enrolledStudySlugs) && user.enrolledStudySlugs.includes(normalized)
} }
// A student who has finished every lesson in a study keeps access to it (lessons, notes,
// certificate) even if they later leave the enrollment. Completion is stored on the user
// so it survives content edits and un-enrolling.
export function isStudyUserCompleted(user, studySlug) {
const normalized = normalizeStudySlug(studySlug)
if (!normalized || !user) return false
return Array.isArray(user.completedStudySlugs) && user.completedStudySlugs.includes(normalized)
}
export function hasStudyAccess(user, studySlug) {
return isStudyUserEnrolled(user, studySlug) || isStudyUserCompleted(user, studySlug)
}
// Enrolled studies plus finished ones, deduplicated. Sent to the client as the list of
// studies the student can open.
export function getAccessibleStudySlugs(user) {
const enrolled = Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : []
const completed = Array.isArray(user?.completedStudySlugs) ? user.completedStudySlugs : []
return [...new Set([...enrolled, ...completed])]
}
// Every lesson (released or not) is required, matching the certificate rules.
export function getRequiredSectionIds(studySlug) {
const content = state.cachedSiteContent
if (!content || !Array.isArray(content.studies)) return null
const study = content.studies.find(item => item?.slug === studySlug)
if (!study || !Array.isArray(study.sections)) return null
return study.sections.filter(item => item?.id).map(item => item.id)
}
// Marks the study completed on the user when every required lesson is done. Sticky: never
// removed, so a later new lesson doesn't revoke access. Returns true if newly completed.
export function markStudyCompletedIfDone(user, studySlug, completedSectionIds) {
const normalized = normalizeStudySlug(studySlug)
if (!normalized || isStudyUserCompleted(user, normalized)) return false
const required = getRequiredSectionIds(normalized)
if (!required || required.length === 0) return false
if (!required.every(id => completedSectionIds.includes(id))) return false
user.completedStudySlugs = [...(Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : []), normalized]
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
return true
}
// One-off pass at startup so students who finished a study before this existed get the flag.
export async function backfillCompletedStudies() {
if (!state.cachedSiteContent) return 0
let updated = 0
for (const user of state.studyUsers) {
const progress = await loadUserProgress(user.id)
// Enrolled studies plus any with saved progress, so students who already left are caught too
const slugs = new Set([
...(Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []),
...Object.keys(progress.byStudy ?? {}),
])
for (const slug of slugs) {
if (markStudyCompletedIfDone(user, slug, progress.byStudy[slug]?.completedSectionIds ?? [])) updated++
}
}
return updated
}
// Discussion (community posts, replies, likes and lesson comments) is open for any study that
// has started: the current study and finished ones, so students who take a study later can
// still talk with each other. Only planned studies (not started yet) are closed.
export function isStudyDiscussionOpen(studySlug) {
const normalized = normalizeStudySlug(studySlug)
const studies = state.cachedSiteContent?.studies
if (!normalized || !Array.isArray(studies)) return false
const study = studies.find(item => normalizeStudySlug(item?.slug) === normalized)
return Boolean(study) && getEffectiveStudyStatus(study) !== 'planned'
}
export const DISCUSSION_CLOSED_MESSAGE = 'Discussion opens once this study has started.'
export function findStudyUserById(userId) { export function findStudyUserById(userId) {
if (typeof userId !== 'string' || !userId.trim()) return undefined if (typeof userId !== 'string' || !userId.trim()) return undefined
return state.studyUsers.find(user => user.id === userId) return state.studyUsers.find(user => user.id === userId)
@@ -109,6 +247,17 @@ export function findStudyUserByUsername(username) {
return state.studyUsers.find(user => user.username === normalizeStudyUsername(username)) return state.studyUsers.find(user => user.username === normalizeStudyUsername(username))
} }
// ── Community display name ──────────────────────────────────────────────────
// Never derived from the student's email — falls back to a stable, anonymous
// handle assigned from their account id so nobody's address leaks into posts.
export function getCommunityDisplayName(user) {
const displayName = typeof user?.displayName === 'string' ? user.displayName.trim() : ''
if (displayName) return displayName
const id = typeof user?.id === 'string' ? user.id : ''
return `Student ${(id.replace(/-/g, '').slice(0, 4) || '0000').toUpperCase()}`
}
// ── Avatar ───────────────────────────────────────────────────────────────── // ── Avatar ─────────────────────────────────────────────────────────────────
export function getStudyAvatarUrl(subject) { export function getStudyAvatarUrl(subject) {
@@ -130,27 +279,36 @@ export function getStudyAvatarUrl(subject) {
// ── Session management ───────────────────────────────────────────────────── // ── Session management ─────────────────────────────────────────────────────
export function cookieFlags() {
return cookieSecureFlag()
}
export function createStudySession(userId) { export function createStudySession(userId) {
const token = randomUUID() const token = randomUUID()
state.studySessions.set(token, { userId, expiresAt: Date.now() + STUDY_SESSION_TTL_MS }) state.studySessions.set(token, { userId, expiresAt: Date.now() + STUDY_SESSION_TTL_MS })
queueStudySessionsWrite()
return token return token
} }
// Revokes every active session for a user (password change/reset, account deletion, "sign out everywhere").
export function invalidateAllStudySessions(userId, exceptToken = null) {
let changed = false
for (const [token, session] of state.studySessions) {
if (session.userId === userId && token !== exceptToken) {
state.studySessions.delete(token)
changed = true
}
}
if (changed) queueStudySessionsWrite()
}
export function setStudySessionCookie(res, token) { export function setStudySessionCookie(res, token) {
res.append( res.append(
'Set-Cookie', 'Set-Cookie',
`${STUDY_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(STUDY_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`, `${STUDY_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(STUDY_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
) )
} }
export function clearStudySessionCookie(res) { export function clearStudySessionCookie(res) {
res.append( res.append(
'Set-Cookie', 'Set-Cookie',
`${STUDY_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`, `${STUDY_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
) )
} }
@@ -233,7 +391,7 @@ export function createTitusDownloadToken(email) {
const token = randomUUID() const token = randomUUID()
state.titusDownloadTokens.set(token, { state.titusDownloadTokens.set(token, {
email, email,
expiresAt: Date.now() + (10 * 60 * 1000), expiresAt: Date.now() + DOWNLOAD_TOKEN_TTL_MS,
}) })
return token return token
} }
@@ -273,9 +431,11 @@ export function isSectionReleased(section) {
export function redactUnreleasedSection(section) { export function redactUnreleasedSection(section) {
if (!section || typeof section !== 'object') return section if (!section || typeof section !== 'object') return section
if (isSectionReleased(section)) return section // episodeNumber is an admin-only reference and is never sent to the public site
const { episodeNumber: _episodeNumber, ...publicSection } = section
if (isSectionReleased(section)) return publicSection
return { return {
...section, ...publicSection,
passageText: '', passageText: '',
commentary: '', commentary: '',
greekNotes: [], greekNotes: [],
@@ -316,6 +476,21 @@ export function normalizeHitPath(pathname) {
return pathname return pathname
} }
// Top-level URL segments the SPA / static pages actually serve. Anything else is a probe or
// scanner hit, which must not count as a real visit or add a new key to the per-path stats.
const KNOWN_SITE_SEGMENTS = new Set([
'start-here', 'study', 'finished', 'episodes', 'nuggets', 'resources', 'downloads', 'about', 'contact',
'questions', 'newsletter', 'scripture', 'thanks', 'subscribe', 'certificate', 'host', 'admin', 'preview',
'privacy', 'terms', 'salvation', 'qr',
])
const MAX_UNKNOWN_PATHS = 300
export function isKnownSitePath(pathname) {
const first = String(pathname ?? '/').split('/')[1] ?? ''
return first === '' || KNOWN_SITE_SEGMENTS.has(first)
}
export function shouldCountHit(req) { export function shouldCountHit(req) {
if (req.method !== 'GET') return false if (req.method !== 'GET') return false
if (req.path.startsWith('/api/')) return false if (req.path.startsWith('/api/')) return false
@@ -374,36 +549,19 @@ export function detectBot(userAgent) {
return { isBot: true, reason: 'http-client' } return { isBot: true, reason: 'http-client' }
} }
if (/bot|crawler|spider|scraper|indexer|reader|fetcher|loader|agent|spyware|tracking|monitor/.test(ua)) { if (/bot|crawler|spider|scraper|indexer|reader|fetcher|loader|agent|spyware|tracking|monitor/.test(ua)) {
if (!/chrome|firefox|safari|opera|edge|msie|trident|like gecko/.test(ua)) { // Generic keywords like "reader" or "agent" can appear as substrings of
// otherwise-normal browser UAs (e.g. some webviews, regional browsers).
// Any UA carrying a real rendering-engine or vendor token is treated as
// a genuine browser rather than a bot, even if it also matches a keyword
// above — this list should stay a superset of the previous one so it
// only ever reduces false positives, never re-admits an actual bot.
if (!/chrome|firefox|safari|opera|edge|msie|trident|gecko|webkit|khtml|presto|crios|fxios|ucbrowser|samsungbrowser/.test(ua)) {
return { isBot: true, reason: 'bot-keyword' } return { isBot: true, reason: 'bot-keyword' }
} }
} }
return { isBot: false, reason: null } return { isBot: false, reason: null }
} }
export function normalizeIp(rawIp) {
if (!rawIp) return 'unknown'
let ip = String(rawIp).trim()
if (ip.includes(',')) ip = ip.split(',')[0].trim()
if (ip.startsWith('::ffff:')) ip = ip.slice(7)
if (ip === '::1') ip = '127.0.0.1'
return ip || 'unknown'
}
export function isPrivateOrLocalIp(ip) {
return (
ip === '127.0.0.1'
|| ip === 'localhost'
|| ip.startsWith('10.')
|| ip.startsWith('192.168.')
|| /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip)
|| ip.startsWith('fc')
|| ip.startsWith('fd')
|| ip.startsWith('fe80:')
|| ip === 'unknown'
)
}
export function buildTopLocations(list, key) { export function buildTopLocations(list, key) {
const counts = {} const counts = {}
for (const row of list) { for (const row of list) {
@@ -437,6 +595,21 @@ export function recordHit(pathname, isBot = false, botReason = null) {
state.hitStats.lastHitAt = nowIso state.hitStats.lastHitAt = nowIso
state.hitStats.firstHitAt = state.hitStats.firstHitAt ?? nowIso state.hitStats.firstHitAt = state.hitStats.firstHitAt ?? nowIso
if (isBot && botReason === 'unknown-path') {
// Probe/scanner URLs: keep a bounded list for the admin view instead of one stats key per URL
const unknown = (state.hitStats.byPathUnknown ??= {})
const key = safePath.slice(0, 120)
if (key in unknown || Object.keys(unknown).length < MAX_UNKNOWN_PATHS) unknown[key] = (unknown[key] ?? 0) + 1
else unknown['(other)'] = (unknown['(other)'] ?? 0) + 1
state.hitStats.botHits += 1
state.hitStats.byPathBot['(unknown)'] = (state.hitStats.byPathBot['(unknown)'] ?? 0) + 1
state.hitStats.byDayBot[dayKey] = (state.hitStats.byDayBot[dayKey] ?? 0) + 1
state.hitStats.botReasons[botReason] = (state.hitStats.botReasons[botReason] ?? 0) + 1
state.hitStats.byPath['(unknown)'] = (state.hitStats.byPath['(unknown)'] ?? 0) + 1
state.hitStats.byDay[dayKey] = (state.hitStats.byDay[dayKey] ?? 0) + 1
return
}
if (isBot) { if (isBot) {
state.hitStats.botHits += 1 state.hitStats.botHits += 1
state.hitStats.byPathBot[safePath] = (state.hitStats.byPathBot[safePath] ?? 0) + 1 state.hitStats.byPathBot[safePath] = (state.hitStats.byPathBot[safePath] ?? 0) + 1
@@ -455,7 +628,6 @@ export function recordHit(pathname, isBot = false, botReason = null) {
} }
export function pruneStatsByDays(daysRaw) { export function pruneStatsByDays(daysRaw) {
const { VISITOR_RETENTION_DAYS_DEFAULT, EMPTY_HIT_STATS: _unused } = { VISITOR_RETENTION_DAYS_DEFAULT: 180 }
const days = Number(daysRaw) const days = Number(daysRaw)
const retentionDays = Number.isFinite(days) && days > 0 ? Math.floor(days) : 180 const retentionDays = Number.isFinite(days) && days > 0 ? Math.floor(days) : 180
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000 const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000
@@ -505,51 +677,6 @@ export function pruneStatsByDays(daysRaw) {
} }
} }
// ── Redirect / URL helpers ─────────────────────────────────────────────────
export function normalizeRedirectPath(value) {
if (typeof value !== 'string') return ''
const trimmed = value.trim()
if (!trimmed) return ''
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
const normalized = withSlash.replace(/\/+/g, '/')
if (normalized === '/') return ''
if (normalized.startsWith('/api/') || normalized.startsWith('/admin')) return ''
return normalized
}
export function sanitizeUrl(value) {
if (typeof value !== 'string') return ''
const trimmed = value.trim()
if (!trimmed) return ''
if (trimmed.startsWith('/')) return trimmed
if (/^https?:\/\//i.test(trimmed)) return trimmed
return ''
}
export function sanitizeRedirectRules(value) {
const source = Array.isArray(value) ? value : []
const seen = new Set()
const out = []
for (const item of source) {
const pathValue = normalizeRedirectPath(item?.path)
const target = sanitizeUrl(item?.target)
const statusCode = Number(item?.statusCode) === 302 ? 302 : 301
if (!pathValue || !target) continue
if (seen.has(pathValue)) continue
seen.add(pathValue)
out.push({
id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
path: pathValue,
target,
statusCode,
})
}
return out.length > 0 ? out : DEFAULT_REDIRECT_RULES
}
// ── Contact / email tracking ─────────────────────────────────────────────── // ── Contact / email tracking ───────────────────────────────────────────────
export function noteContactEmailCooldown(emailAddress) { export function noteContactEmailCooldown(emailAddress) {
@@ -609,9 +736,21 @@ export function extractResendMessageId(result) {
return '' return ''
} }
export function normalizeMessageType(value) { // One-off pass: lesson reminders are now on by default, so switch them on for accounts that
if (value === 'question' || value === 'testimony' || value === 'topic') return value // predate the default. Each account is marked as it is handled, so a later opt-out sticks.
return 'general' export async function backfillReminderDefault() {
const now = new Date().toISOString()
let updated = 0
for (const user of state.studyUsers) {
if (user.remindersDefaultedAt) continue
if (user.studyRemindersEnabled !== true) {
user.studyRemindersEnabled = true
updated++
}
user.remindersDefaultedAt = now
}
if (state.studyUsers.some(user => user.remindersDefaultedAt === now)) queueStudyUsersWrite()
return updated
} }
// ── Study reminder scheduler ─────────────────────────────────────────────── // ── Study reminder scheduler ───────────────────────────────────────────────
@@ -622,6 +761,9 @@ export async function scheduleStudyReminders(sendStudyReminderEmail) {
for (const user of state.studyUsers) { for (const user of state.studyUsers) {
if (user.studyRemindersEnabled !== true) continue if (user.studyRemindersEnabled !== true) continue
if (user.reminderFrequency === 'weekly') continue
const pausedUntil = user.remindersPausedUntil ? new Date(user.remindersPausedUntil) : null
if (pausedUntil && !isNaN(pausedUntil.getTime()) && pausedUntil > now) continue
const email = user.username const email = user.username
const displayName = user.displayName || email const displayName = user.displayName || email
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [] const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
@@ -629,6 +771,7 @@ export async function scheduleStudyReminders(sendStudyReminderEmail) {
const userSent = state.studyReminders.users[user.id] ?? {} const userSent = state.studyReminders.users[user.id] ?? {}
for (const studySlug of enrolledStudySlugs) { for (const studySlug of enrolledStudySlugs) {
if (isStudyUserCompleted(user, studySlug)) continue // finished studies don't need lesson reminders
const study = Array.isArray(state.cachedSiteContent.studies) const study = Array.isArray(state.cachedSiteContent.studies)
? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug) ? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
: undefined : undefined
@@ -677,7 +820,7 @@ function getReengagementTiers() {
return days.slice(0, 3).map((d, i) => ({ key: `t${i + 1}`, days: d })) return days.slice(0, 3).map((d, i) => ({ key: `t${i + 1}`, days: d }))
} }
function makeUnsubUrl(userId) { export function makeUnsubUrl(userId) {
const secret = process.env.RESEND_WEBHOOK_TOKEN ?? 'siteforge' const secret = process.env.RESEND_WEBHOOK_TOKEN ?? 'siteforge'
const token = createHmac('sha256', secret).update(userId).digest('hex') const token = createHmac('sha256', secret).update(userId).digest('hex')
const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/' const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
@@ -694,6 +837,8 @@ export async function scheduleReengagementEmails(sendStudyReengagementEmail) {
for (const user of state.studyUsers) { for (const user of state.studyUsers) {
if (user.studyRemindersEnabled !== true) continue if (user.studyRemindersEnabled !== true) continue
if (!user.lastLoginAt) continue if (!user.lastLoginAt) continue
const pausedUntil = user.remindersPausedUntil ? new Date(user.remindersPausedUntil) : null
if (pausedUntil && !isNaN(pausedUntil.getTime()) && pausedUntil > now) continue
const lastLogin = new Date(user.lastLoginAt) const lastLogin = new Date(user.lastLoginAt)
if (isNaN(lastLogin.getTime())) continue if (isNaN(lastLogin.getTime())) continue
const daysSinceLogin = (nowMs - lastLogin.getTime()) / (1000 * 60 * 60 * 24) const daysSinceLogin = (nowMs - lastLogin.getTime()) / (1000 * 60 * 60 * 24)
@@ -702,7 +847,9 @@ export async function scheduleReengagementEmails(sendStudyReengagementEmail) {
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [] const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
const studies = Array.isArray(state.cachedSiteContent.studies) ? state.cachedSiteContent.studies : [] const studies = Array.isArray(state.cachedSiteContent.studies) ? state.cachedSiteContent.studies : []
// Studies the student already finished aren't "unfinished business" for a come-back nudge.
const enrolledStudies = enrolledStudySlugs const enrolledStudies = enrolledStudySlugs
.filter(slug => !isStudyUserCompleted(user, slug))
.map(slug => studies.find(s => normalizeStudySlug(s?.slug) === slug)) .map(slug => studies.find(s => normalizeStudySlug(s?.slug) === slug))
.filter(Boolean) .filter(Boolean)
@@ -733,3 +880,82 @@ export async function scheduleReengagementEmails(sendStudyReengagementEmail) {
} }
} }
} }
// ── Weekly digest scheduler ───────────────────────────────────────────────
export async function scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail) {
if (!state.cachedSiteContent) return
const now = new Date()
const nowMs = now.getTime()
const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000
for (const user of state.studyUsers) {
if (user.reminderFrequency !== 'weekly') continue
if (user.studyRemindersEnabled !== true) continue
const pausedUntil = user.remindersPausedUntil ? new Date(user.remindersPausedUntil) : null
if (pausedUntil && !isNaN(pausedUntil.getTime()) && pausedUntil > now) continue
const lastDigestMs = user.weeklyDigestSentAt ? new Date(user.weeklyDigestSentAt).getTime() : 0
if (nowMs - lastDigestMs < ONE_WEEK_MS) continue
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
if (enrolledStudySlugs.length === 0) continue
const userSent = state.studyReminders.users[user.id] ?? {}
const lessons = []
const newlySentByStudy = {}
for (const studySlug of enrolledStudySlugs) {
if (isStudyUserCompleted(user, studySlug)) continue
const study = Array.isArray(state.cachedSiteContent.studies)
? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
: undefined
if (!study) continue
// Finished studies are self-paced: only mention lessons from the last week so a
// late enrollee isn't sent the whole back catalogue in one digest.
const isComplete = getEffectiveStudyStatus(study) === 'complete'
for (const section of study.sections ?? []) {
const releaseTime = getSectionReleaseTime(section)
if (!Number.isFinite(releaseTime) || releaseTime > nowMs) continue
if (isComplete && nowMs - releaseTime > ONE_WEEK_MS) continue
const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
if (sentForStudy.includes(section.id)) continue
const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
lessons.push({
sectionId: section.id,
studySlug,
sectionTitle: section.title ?? section.id,
sectionReference: section.reference ?? '',
studyTitle: study.title ?? studySlug,
sectionUrl: `${base}/study/${study.slug}/${section.id}`,
})
if (!newlySentByStudy[studySlug]) newlySentByStudy[studySlug] = []
newlySentByStudy[studySlug].push(section.id)
}
}
if (lessons.length === 0) continue
const unsubUrl = makeUnsubUrl(user.id)
try {
await sendStudyWeeklyDigestEmail(user.username, user.displayName || user.username, lessons, unsubUrl)
} catch (err) {
console.error(`[study-digest] failed to send digest to ${user.username}:`, err)
continue
}
for (const [studySlug, sectionIds] of Object.entries(newlySentByStudy)) {
const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
userSent[studySlug] = [...new Set([...sentForStudy, ...sectionIds])]
}
state.studyReminders.users[user.id] = userSent
queueStudyRemindersWrite()
user.weeklyDigestSentAt = now.toISOString()
queueStudyUsersWrite()
}
}
+50
View File
@@ -0,0 +1,50 @@
import { lookup } from 'node:dns/promises'
import net from 'node:net'
function isPrivateIPv4(ip) {
const [a, b] = ip.split('.').map(Number)
return (
a === 0 || a === 10 || a === 127
|| (a === 100 && b >= 64 && b <= 127) // CGNAT / Tailscale
|| (a === 169 && b === 254) // link-local, cloud metadata
|| (a === 172 && b >= 16 && b <= 31)
|| (a === 192 && b === 168)
|| a >= 224 // multicast / reserved
)
}
export function isPrivateAddress(ip) {
if (net.isIPv4(ip)) return isPrivateIPv4(ip)
if (net.isIPv6(ip)) {
const lower = ip.toLowerCase()
if (lower === '::' || lower === '::1') return true
if (lower.startsWith('fc') || lower.startsWith('fd')) return true // unique local
if (/^fe[89ab]/.test(lower)) return true // link-local
const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)
if (mapped) return isPrivateIPv4(mapped[1])
return false
}
return true // not an IP at all: treat as unsafe
}
// Throws unless `rawUrl` is an https URL whose host resolves only to public
// addresses. Called when a webhook is registered and again before each delivery
// (DNS can change between the two).
export async function assertPublicHttpsUrl(rawUrl) {
let parsed
try {
parsed = new URL(rawUrl)
} catch {
throw new Error('Malformed URL.')
}
if (parsed.protocol !== 'https:') throw new Error('Only https:// URLs are allowed.')
if (parsed.username || parsed.password) throw new Error('URLs with embedded credentials are not allowed.')
const host = parsed.hostname.replace(/^\[|\]$/g, '')
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal')) {
throw new Error('Internal hostnames are not allowed.')
}
const addresses = net.isIP(host) ? [{ address: host }] : await lookup(host, { all: true })
if (addresses.length === 0 || addresses.some(a => isPrivateAddress(a.address))) {
throw new Error('URL resolves to a private or internal address.')
}
}
+118
View File
@@ -0,0 +1,118 @@
// Canonical Protestant Bible book list, in canonical (Genesis → Revelation) order.
//
// This is the single source of truth for book names/aliases, shared between the
// client (src/App.tsx, for the Scripture Index page) and the server
// (server/routes/bible-passage.js, for the BSB/bolls.life passage-fetch API).
// Previously these lived as three independent, hand-maintained lists with
// different key formats (spaced names + 3-letter ids in the client, unspaced
// names + numeric ids on the server) that could silently drift out of sync.
//
// - `id` is the 3-letter USFM-style book id used by the bible.helloao.org API.
// - bolls.life's book numbering is exactly this array's 1-based canonical order,
// so no separate numeric-id table is needed — see bookNumberFor() below.
// - `aliases` are additional short forms admins/users might type (abbreviations).
export const BIBLE_BOOKS = [
{ id: 'GEN', name: 'Genesis', aliases: ['gen'] },
{ id: 'EXO', name: 'Exodus', aliases: ['exo'] },
{ id: 'LEV', name: 'Leviticus', aliases: ['lev'] },
{ id: 'NUM', name: 'Numbers', aliases: ['num'] },
{ id: 'DEU', name: 'Deuteronomy', aliases: ['deut'] },
{ id: 'JOS', name: 'Joshua', aliases: ['josh'] },
{ id: 'JDG', name: 'Judges', aliases: ['judg'] },
{ id: 'RUT', name: 'Ruth' },
{ id: 'SA1', name: '1 Samuel', aliases: ['1 sam'] },
{ id: 'SA2', name: '2 Samuel', aliases: ['2 sam'] },
{ id: 'KI1', name: '1 Kings', aliases: ['1 kgs'] },
{ id: 'KI2', name: '2 Kings', aliases: ['2 kgs'] },
{ id: 'CH1', name: '1 Chronicles', aliases: ['1 chr'] },
{ id: 'CH2', name: '2 Chronicles', aliases: ['2 chr'] },
{ id: 'EZR', name: 'Ezra' },
{ id: 'NEH', name: 'Nehemiah', aliases: ['neh'] },
{ id: 'EST', name: 'Esther', aliases: ['esth'] },
{ id: 'JOB', name: 'Job' },
{ id: 'PSA', name: 'Psalms', aliases: ['ps', 'psa', 'psalm'] },
{ id: 'PRO', name: 'Proverbs', aliases: ['prov'] },
{ id: 'ECC', name: 'Ecclesiastes', aliases: ['eccl'] },
{ id: 'SNG', name: 'Song of Solomon', aliases: ['song'] },
{ id: 'ISA', name: 'Isaiah', aliases: ['isa'] },
{ id: 'JER', name: 'Jeremiah', aliases: ['jer'] },
{ id: 'LAM', name: 'Lamentations', aliases: ['lam'] },
{ id: 'EZK', name: 'Ezekiel', aliases: ['ezek'] },
{ id: 'DAN', name: 'Daniel', aliases: ['dan'] },
{ id: 'HOS', name: 'Hosea', aliases: ['hos'] },
{ id: 'JOL', name: 'Joel' },
{ id: 'AMO', name: 'Amos' },
{ id: 'OBA', name: 'Obadiah', aliases: ['obad'] },
{ id: 'JNA', name: 'Jonah' },
{ id: 'MIC', name: 'Micah', aliases: ['mic'] },
{ id: 'NAH', name: 'Nahum', aliases: ['nah'] },
{ id: 'HAB', name: 'Habakkuk', aliases: ['hab'] },
{ id: 'ZEP', name: 'Zephaniah', aliases: ['zeph'] },
{ id: 'HAG', name: 'Haggai', aliases: ['hag'] },
{ id: 'ZEC', name: 'Zechariah', aliases: ['zech'] },
{ id: 'MAL', name: 'Malachi', aliases: ['mal'] },
{ id: 'MAT', name: 'Matthew', aliases: ['matt'] },
{ id: 'MRK', name: 'Mark' },
{ id: 'LUK', name: 'Luke' },
{ id: 'JHN', name: 'John' },
{ id: 'ACT', name: 'Acts' },
{ id: 'ROM', name: 'Romans', aliases: ['rom'] },
{ id: 'CO1', name: '1 Corinthians', aliases: ['1 cor'] },
{ id: 'CO2', name: '2 Corinthians', aliases: ['2 cor'] },
{ id: 'GAL', name: 'Galatians', aliases: ['gal'] },
{ id: 'EPH', name: 'Ephesians', aliases: ['eph'] },
{ id: 'PHP', name: 'Philippians', aliases: ['phil'] },
{ id: 'COL', name: 'Colossians', aliases: ['col'] },
{ id: 'TH1', name: '1 Thessalonians', aliases: ['1 thess'] },
{ id: 'TH2', name: '2 Thessalonians', aliases: ['2 thess'] },
{ id: 'TI1', name: '1 Timothy', aliases: ['1 tim'] },
{ id: 'TI2', name: '2 Timothy', aliases: ['2 tim'] },
{ id: 'TIT', name: 'Titus', aliases: ['tit'] },
{ id: 'PHM', name: 'Philemon', aliases: ['philem'] },
{ id: 'HEB', name: 'Hebrews', aliases: ['heb'] },
{ id: 'JAM', name: 'James', aliases: ['jas'] },
{ id: 'PE1', name: '1 Peter', aliases: ['1 pet'] },
{ id: 'PE2', name: '2 Peter', aliases: ['2 pet'] },
{ id: 'JO1', name: '1 John' },
{ id: 'JO2', name: '2 John' },
{ id: 'JO3', name: '3 John' },
{ id: 'JDE', name: 'Jude', aliases: ['jude'] },
{ id: 'REV', name: 'Revelation', aliases: ['rev'] },
]
/** bolls.life's numeric book id: just this list's 1-based canonical position. */
export function bookNumberFor(usfmId) {
const index = BIBLE_BOOKS.findIndex(b => b.id === usfmId)
return index === -1 ? null : index + 1
}
let referenceBookLookup = null
/**
* Checks a passage reference the way the Scripture Index and passage fetch read it.
* `bookHint` is the study's Bible Book field (or slug) and is used when the reference
* has no book of its own ("1:1-2"). Returns null when it will work, otherwise a short
* message explaining what to change.
*/
export function checkPassageReference(reference, bookHint) {
if (!referenceBookLookup) {
referenceBookLookup = new Set()
for (const b of BIBLE_BOOKS) {
for (const key of [b.name.toLowerCase(), ...(b.aliases ?? [])]) {
referenceBookLookup.add(key)
referenceBookLookup.add(key.replace(/\s+/g, ''))
}
}
}
const clean = String(reference ?? '').replace(/[–—]/g, '-').replace(/(\d)[a-z]\b/gi, '$1').trim()
if (!clean) return null
const m = clean.match(/^(?:(.*?)\s+)?(\d+):(\d+)(?:-(?:(\d+):)?(\d+))?$/)
if (!m) return 'Use the form 1:1-2 (chapter:verse, optional range). It will be listed under "Other references" in the Scripture Index until fixed.'
const book = (m[1] ?? String(bookHint ?? '')).toLowerCase().replace(/\.\s*/g, ' ').trim()
if (!book || !referenceBookLookup.has(book)) {
return m[1]
? `"${m[1]}" is not a recognized Bible book name.`
: 'Set this study\'s "Bible Book" field (e.g. titus) so this reference can be matched to a book.'
}
return null
}
+14
View File
@@ -0,0 +1,14 @@
export type NoteAnchorId = 'scripture' | 'commentary' | 'questions' | 'general'
export interface NoteEntry {
id: string
anchor: NoteAnchorId
html: string
updatedAt: string | null
}
export const NOTE_ANCHORS: NoteAnchorId[]
export const NOTE_ANCHOR_LABELS: Record<NoteAnchorId, string>
export function newNoteId(): string
export function parseNoteEntries(raw: string): NoteEntry[]
export function serializeNoteEntries(entries: NoteEntry[]): string
export function noteHtmlToText(html: string): string
export function noteEntriesToText(raw: string): string
+79
View File
@@ -0,0 +1,79 @@
// Lesson note storage format, shared by the client and the server.
//
// Each lesson's notes are stored as one string. Current format (v2):
// {"v":2,"entries":[{"id","anchor","html","updatedAt"}]}
// Legacy format (v1) was a JSON object of { anchor: html }, and older still was
// plain text. Both are still read and are upgraded to v2 on the next save.
export const NOTE_ANCHORS = ['scripture', 'commentary', 'questions', 'general']
export const NOTE_ANCHOR_LABELS = {
scripture: 'Scripture Text',
commentary: 'Commentary',
questions: 'Reflection Questions',
general: 'General Notes',
}
export function newNoteId() {
return `n${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`
}
export function parseNoteEntries(raw) {
if (typeof raw !== 'string' || !raw.trim()) return []
let parsed = null
try {
parsed = JSON.parse(raw)
} catch {
// Legacy plain-text note.
return [{ id: newNoteId(), anchor: 'general', html: raw, updatedAt: null }]
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return [{ id: newNoteId(), anchor: 'general', html: raw, updatedAt: null }]
}
if (parsed.v === 2 && Array.isArray(parsed.entries)) {
return parsed.entries
.filter(e => e && typeof e.html === 'string' && e.html.trim())
.map(e => ({
id: typeof e.id === 'string' && e.id ? e.id : newNoteId(),
anchor: NOTE_ANCHORS.includes(e.anchor) ? e.anchor : 'general',
html: e.html,
updatedAt: typeof e.updatedAt === 'string' ? e.updatedAt : null,
}))
}
return Object.entries(parsed)
.filter(([, html]) => typeof html === 'string' && html.trim())
.map(([anchor, html]) => ({
id: `legacy-${anchor}`,
anchor: NOTE_ANCHORS.includes(anchor) ? anchor : 'general',
html,
updatedAt: null,
}))
}
export function serializeNoteEntries(entries) {
const kept = entries.filter(e => e.html && e.html.trim())
if (kept.length === 0) return ''
return JSON.stringify({ v: 2, entries: kept })
}
export function noteHtmlToText(html) {
return String(html)
.replace(/<\/(p|li|h[1-6]|blockquote)>/gi, '\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
// Plain-text rendering of a whole lesson's notes (used by the Word export).
export function noteEntriesToText(raw) {
return parseNoteEntries(raw)
.map(e => `${NOTE_ANCHOR_LABELS[e.anchor]}\n${noteHtmlToText(e.html)}`)
.join('\n\n')
}
+2939 -683
View File
File diff suppressed because it is too large. Load diff
+4022 -2172
View File
File diff suppressed because it is too large. Load diff
+1974 -845
View File
File diff suppressed because it is too large. Load diff
+533
View File
@@ -0,0 +1,533 @@
import { useMemo, useState } from 'react'
import type { ChangeEvent } from 'react'
export interface AdminAsset {
filename: string
key?: string
storage?: 'r2' | 'local'
url: string
folder?: string
sizeBytes: number
updatedAt: string | null
tags?: string[]
usedIn?: number
}
interface AssetManagerProps {
assets: AdminAsset[]
onReload: () => Promise<void>
onMessage: (message: string) => void
message: string
}
type SortKey = 'newest' | 'oldest' | 'name' | 'size' | 'used'
type TypeFilter = 'all' | 'image' | 'pdf' | 'doc'
type ViewMode = 'list' | 'grid'
const MOVE_FOLDERS = ['uploads', 'images', 'guides', 'finished']
const MAX_UPLOAD_BYTES = 8 * 1024 * 1024
const assetId = (a: AdminAsset) => a.key ?? `${a.folder ?? ''}/${a.filename}`
const isImage = (name: string) => /\.(png|jpe?g|webp|gif)$/i.test(name)
const isPdf = (name: string) => /\.pdf$/i.test(name)
const fileKind = (name: string): TypeFilter => (isImage(name) ? 'image' : isPdf(name) ? 'pdf' : 'doc')
const extOf = (name: string) => (name.match(/\.[a-z0-9]+$/i)?.[0] ?? '').toLowerCase()
function formatSize(bytes: number) {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${Math.max(bytes / 1024, 0.1).toFixed(1)} KB`
}
function formatWhen(value: string | null) {
if (!value) return '—'
const d = new Date(value)
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
function locationLabel(a: AdminAsset) {
if (a.storage === 'local' || a.folder === 'local') return 'Local disk'
return a.folder ? `R2 / ${a.folder}` : 'R2'
}
async function readError(res: Response, fallback: string) {
const data = await res.json().catch(() => ({})) as { message?: string }
return data.message ?? fallback
}
export default function AssetManager({ assets, onReload, onMessage, message }: AssetManagerProps) {
const [search, setSearch] = useState('')
const [folderFilter, setFolderFilter] = useState('all')
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all')
const [tagFilter, setTagFilter] = useState<string | null>(null)
const [unusedOnly, setUnusedOnly] = useState(false)
const [sort, setSort] = useState<SortKey>('newest')
const [view, setView] = useState<ViewMode>('list')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [busy, setBusy] = useState(false)
const [uploadDest, setUploadDest] = useState('r2:uploads')
const [renaming, setRenaming] = useState<{ id: string; value: string } | null>(null)
const [tagDrafts, setTagDrafts] = useState<Record<string, string>>({})
const [copiedId, setCopiedId] = useState<string | null>(null)
const [bulkMove, setBulkMove] = useState('')
const folders = useMemo(() => {
const counts = new Map<string, number>()
for (const a of assets) {
const key = a.storage === 'local' || a.folder === 'local' ? 'local' : (a.folder || 'r2')
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]))
}, [assets])
const allTags = useMemo(() => {
const counts = new Map<string, number>()
for (const a of assets) for (const t of a.tags ?? []) counts.set(t, (counts.get(t) ?? 0) + 1)
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
}, [assets])
const visible = useMemo(() => {
const q = search.trim().toLowerCase()
const list = assets.filter(a => {
const folderKey = a.storage === 'local' || a.folder === 'local' ? 'local' : (a.folder || 'r2')
if (folderFilter !== 'all' && folderKey !== folderFilter) return false
if (typeFilter !== 'all' && fileKind(a.filename) !== typeFilter) return false
if (tagFilter && !(a.tags ?? []).includes(tagFilter)) return false
if (unusedOnly && (a.usedIn ?? 0) > 0) return false
if (q) {
const haystack = `${a.filename} ${a.folder ?? ''} ${(a.tags ?? []).join(' ')}`.toLowerCase()
if (!q.split(/\s+/).every(word => haystack.includes(word))) return false
}
return true
})
const dateOf = (a: AdminAsset) => a.updatedAt ?? ''
list.sort((a, b) => {
switch (sort) {
case 'oldest': return dateOf(a).localeCompare(dateOf(b))
case 'name': return a.filename.localeCompare(b.filename, undefined, { numeric: true })
case 'size': return b.sizeBytes - a.sizeBytes
case 'used': return (b.usedIn ?? 0) - (a.usedIn ?? 0)
default: return dateOf(b).localeCompare(dateOf(a))
}
})
return list
}, [assets, search, folderFilter, typeFilter, tagFilter, unusedOnly, sort])
const visibleIds = visible.map(assetId)
const selectedVisible = visibleIds.filter(id => selected.has(id))
const allVisibleSelected = visible.length > 0 && selectedVisible.length === visible.length
const totalBytes = assets.reduce((sum, a) => sum + a.sizeBytes, 0)
const filtersActive = Boolean(search || folderFilter !== 'all' || typeFilter !== 'all' || tagFilter || unusedOnly)
const selectedAssets = assets.filter(a => selected.has(assetId(a)))
function clearFilters() {
setSearch('')
setFolderFilter('all')
setTypeFilter('all')
setTagFilter(null)
setUnusedOnly(false)
}
function toggleSelected(id: string) {
setSelected(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function toggleAllVisible() {
setSelected(prev => {
const next = new Set(prev)
if (allVisibleSelected) visibleIds.forEach(id => next.delete(id))
else visibleIds.forEach(id => next.add(id))
return next
})
}
async function handleUpload(event: ChangeEvent<HTMLInputElement>) {
const files = Array.from(event.target.files ?? [])
event.target.value = ''
if (files.length === 0) return
setBusy(true)
onMessage('')
const [storage, folder] = uploadDest === 'local' ? ['local', 'uploads'] : ['r2', uploadDest.replace('r2:', '')]
let ok = 0
const failures: string[] = []
for (const file of files) {
try {
if (file.size > MAX_UPLOAD_BYTES) throw new Error('over the 8 MB limit')
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result ?? ''))
reader.onerror = () => reject(new Error('could not read file'))
reader.readAsDataURL(file)
})
const res = await fetch('/api/admin-assets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, dataUrl, storage, folder }),
})
if (!res.ok) throw new Error(await readError(res, 'upload failed'))
ok += 1
} catch (err) {
failures.push(`${file.name} (${err instanceof Error ? err.message : 'failed'})`)
}
}
await onReload().catch(() => {})
setBusy(false)
onMessage(failures.length
? `Uploaded ${ok} of ${files.length}. Failed: ${failures.join('; ')}`
: `Uploaded ${ok} file${ok === 1 ? '' : 's'}.`)
}
async function saveTags(asset: AdminAsset, tags: string[]) {
try {
const res = await fetch(`/api/admin-assets/${encodeURIComponent(asset.filename)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tags }),
})
if (!res.ok) throw new Error(await readError(res, 'Save failed'))
await onReload()
} catch (err) {
onMessage(err instanceof Error ? err.message : 'Save failed.')
}
}
function addTags(asset: AdminAsset) {
const id = assetId(asset)
const incoming = (tagDrafts[id] ?? '').split(',').map(t => t.trim()).filter(Boolean)
setTagDrafts(d => ({ ...d, [id]: '' }))
if (incoming.length === 0) return
const merged = [...new Set([...(asset.tags ?? []), ...incoming])]
void saveTags(asset, merged)
}
function warnIfUsed(asset: AdminAsset, action: string) {
const used = asset.usedIn ?? 0
if (used === 0) return true
return confirm(`${asset.filename} is used ${used} time${used === 1 ? '' : 's'} in your site content. ${action} changes its URL, so those places will show a broken file until you re-select it. Continue?`)
}
async function patchAsset(asset: AdminAsset, body: Record<string, unknown>) {
const res = await fetch(`/api/admin-assets/${encodeURIComponent(asset.filename)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error(await readError(res, 'Update failed'))
}
async function commitRename(asset: AdminAsset) {
const value = renaming?.value.trim() ?? ''
setRenaming(null)
if (!value || value === asset.filename || `${value}${extOf(asset.filename)}` === asset.filename) return
if (!warnIfUsed(asset, 'Renaming')) return
setBusy(true)
try {
await patchAsset(asset, { newName: value })
await onReload()
onMessage(`Renamed ${asset.filename}.`)
} catch (err) {
onMessage(err instanceof Error ? err.message : 'Rename failed.')
} finally {
setBusy(false)
}
}
async function moveAssets(targets: AdminAsset[], folder: string) {
const movable = targets.filter(a => a.storage !== 'local' && a.folder !== 'local' && a.folder !== folder)
if (movable.length === 0) {
onMessage('Nothing to move — local files can’t be moved, and these are already in that folder.')
return
}
const usedCount = movable.filter(a => (a.usedIn ?? 0) > 0).length
if (usedCount > 0 && !confirm(`${usedCount} of the ${movable.length} file${movable.length === 1 ? '' : 's'} you’re moving are used in site content. Moving changes their URLs, so those places will show a broken file until re-selected. Continue?`)) return
setBusy(true)
let ok = 0
const failures: string[] = []
for (const asset of movable) {
try {
await patchAsset(asset, { folder })
ok += 1
} catch (err) {
failures.push(`${asset.filename} (${err instanceof Error ? err.message : 'failed'})`)
}
}
await onReload().catch(() => {})
setBusy(false)
setSelected(new Set())
setBulkMove('')
onMessage(failures.length ? `Moved ${ok}. Failed: ${failures.join('; ')}` : `Moved ${ok} file${ok === 1 ? '' : 's'} to ${folder}/.`)
}
async function deleteAssets(targets: AdminAsset[]) {
if (targets.length === 0) return
const used = targets.filter(a => (a.usedIn ?? 0) > 0).length
const label = targets.length === 1 ? targets[0].filename : `${targets.length} files`
const warning = used > 0 ? `\n\n${used} of these ${used === 1 ? 'is' : 'are'} used in your site content and will show as broken.` : ''
if (!confirm(`Permanently delete ${label}?${warning}`)) return
setBusy(true)
let ok = 0
for (const asset of targets) {
try {
const qs = asset.folder ? `?folder=${encodeURIComponent(asset.folder)}` : ''
const res = await fetch(`/api/admin-assets/${encodeURIComponent(asset.filename)}${qs}`, { method: 'DELETE' })
if (res.ok) ok += 1
} catch { /* counted as failed below */ }
}
await onReload().catch(() => {})
setBusy(false)
setSelected(new Set())
onMessage(ok === targets.length ? `Deleted ${ok} file${ok === 1 ? '' : 's'}.` : `Deleted ${ok} of ${targets.length}.`)
}
function copyUrl(asset: AdminAsset) {
const absolute = asset.url.startsWith('http') ? asset.url : `${window.location.origin}${asset.url}`
navigator.clipboard.writeText(absolute).then(() => {
setCopiedId(assetId(asset))
setTimeout(() => setCopiedId(id => (id === assetId(asset) ? null : id)), 1500)
}).catch(() => onMessage('Could not copy to clipboard.'))
}
function renderThumb(asset: AdminAsset) {
if (isImage(asset.filename)) {
return <img src={asset.url} alt="" loading="lazy" className="am-thumb-img" />
}
return <span className="am-thumb-file">{extOf(asset.filename).replace('.', '').toUpperCase() || 'FILE'}</span>
}
function renderTags(asset: AdminAsset) {
const id = assetId(asset)
return (
<div className="am-tags">
{(asset.tags ?? []).map(tag => (
<span key={tag} className="am-tag">
<button type="button" className="am-tag-label" onClick={() => setTagFilter(tag)} title={`Filter by "${tag}"`}>{tag}</button>
<button type="button" className="am-tag-x" aria-label={`Remove tag ${tag}`} onClick={() => void saveTags(asset, (asset.tags ?? []).filter(t => t !== tag))}>×</button>
</span>
))}
<input
type="text"
className="am-tag-input"
placeholder="+ tag"
value={tagDrafts[id] ?? ''}
onChange={e => setTagDrafts(d => ({ ...d, [id]: e.target.value }))}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTags(asset) } }}
onBlur={() => addTags(asset)}
/>
</div>
)
}
function renderActions(asset: AdminAsset) {
const id = assetId(asset)
const canMove = asset.storage !== 'local' && asset.folder !== 'local'
return (
<div className="am-actions">
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => copyUrl(asset)}>
{copiedId === id ? 'Copied ✓' : 'Copy URL'}
</button>
<a className="btn-admin-reset btn-admin-reset--compact" href={asset.url} target="_blank" rel="noreferrer">Open</a>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={busy} onClick={() => setRenaming({ id, value: asset.filename.replace(/\.[a-z0-9]+$/i, '') })}>Rename</button>
{canMove && (
<select
className="am-select am-select--compact"
value=""
disabled={busy}
aria-label={`Move ${asset.filename}`}
onChange={e => {
const folder = e.target.value
if (folder && warnIfUsed(asset, 'Moving')) void moveAssets([asset], folder)
}}
>
<option value="">Move to…</option>
{MOVE_FOLDERS.filter(f => f !== asset.folder).map(f => <option key={f} value={f}>{f}/</option>)}
</select>
)}
<button type="button" className="btn-admin-remove" disabled={busy} onClick={() => void deleteAssets([asset])}>Delete</button>
</div>
)
}
function renderName(asset: AdminAsset) {
const id = assetId(asset)
if (renaming?.id === id) {
return (
<div className="am-rename">
<input
autoFocus
type="text"
className="am-rename-input"
value={renaming.value}
onChange={e => setRenaming({ id, value: e.target.value })}
onKeyDown={e => {
if (e.key === 'Enter') void commitRename(asset)
if (e.key === 'Escape') setRenaming(null)
}}
/>
<span className="am-rename-ext">{extOf(asset.filename)}</span>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => void commitRename(asset)}>Save</button>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => setRenaming(null)}>Cancel</button>
</div>
)
}
return <a className="am-name" href={asset.url} target="_blank" rel="noreferrer" title={asset.filename}>{asset.filename}</a>
}
function renderMeta(asset: AdminAsset) {
const used = asset.usedIn ?? 0
return (
<div className="am-meta">
<span className="am-badge">{locationLabel(asset)}</span>
<span>{formatSize(asset.sizeBytes)}</span>
<span>{formatWhen(asset.updatedAt)}</span>
<span className={used > 0 ? 'am-used' : 'am-unused'}>{used > 0 ? `Used ${used}×` : 'Unused'}</span>
</div>
)
}
return (
<section className="admin-panel-section am-root" aria-label="Asset manager">
<div className="admin-panel-head">
<h2>Asset Manager</h2>
<p>Upload, find, tag, rename, and organize the images and files hosted on this site.</p>
</div>
<div className="am-toolbar">
<input
type="search"
className="am-search"
placeholder="Search by name, folder, or tag…"
value={search}
onChange={e => setSearch(e.target.value)}
aria-label="Search assets"
/>
<select className="am-select" value={folderFilter} onChange={e => setFolderFilter(e.target.value)} aria-label="Filter by folder">
<option value="all">All folders</option>
{folders.map(([name, count]) => <option key={name} value={name}>{name === 'local' ? 'Local disk' : `${name}/`} ({count})</option>)}
</select>
<select className="am-select" value={typeFilter} onChange={e => setTypeFilter(e.target.value as TypeFilter)} aria-label="Filter by type">
<option value="all">All types</option>
<option value="image">Images</option>
<option value="pdf">PDFs</option>
<option value="doc">Word docs</option>
</select>
<select className="am-select" value={sort} onChange={e => setSort(e.target.value as SortKey)} aria-label="Sort assets">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="name">Name A–Z</option>
<option value="size">Largest first</option>
<option value="used">Most used</option>
</select>
<div className="am-view-toggle" role="group" aria-label="View">
<button type="button" className={view === 'list' ? 'is-active' : ''} onClick={() => setView('list')}>List</button>
<button type="button" className={view === 'grid' ? 'is-active' : ''} onClick={() => setView('grid')}>Grid</button>
</div>
</div>
<div className="am-upload-bar">
<select className="am-select" value={uploadDest} onChange={e => setUploadDest(e.target.value)} disabled={busy} aria-label="Upload destination">
<option value="r2:uploads">Upload to R2 – uploads/</option>
<option value="r2:images">Upload to R2 – images/</option>
<option value="r2:guides">Upload to R2 – guides/</option>
<option value="r2:finished">Upload to R2 – finished/</option>
<option value="local">Upload to local server disk</option>
</select>
<label className="btn-admin-reset" style={{ cursor: busy ? 'wait' : 'pointer' }}>
{busy ? 'Working…' : 'Upload files'}
<input type="file" multiple accept="image/*,.pdf,.doc,.docx" style={{ display: 'none' }} onChange={handleUpload} disabled={busy} />
</label>
<label className="am-check-inline">
<input type="checkbox" checked={unusedOnly} onChange={e => setUnusedOnly(e.target.checked)} /> Unused only
</label>
</div>
{allTags.length > 0 && (
<div className="am-tagbar" aria-label="Filter by tag">
<span className="am-tagbar-label">Tags:</span>
{allTags.map(([tag, count]) => (
<button
key={tag}
type="button"
className={`am-chip${tagFilter === tag ? ' is-active' : ''}`}
onClick={() => setTagFilter(tagFilter === tag ? null : tag)}
>
{tag} <span className="am-chip-count">{count}</span>
</button>
))}
</div>
)}
<div className="am-summary">
<label className="am-check-inline">
<input type="checkbox" checked={allVisibleSelected} onChange={toggleAllVisible} disabled={visible.length === 0} /> Select all shown
</label>
<span>
Showing {visible.length} of {assets.length} · {formatSize(totalBytes)} total
</span>
{filtersActive && <button type="button" className="am-link-btn" onClick={clearFilters}>Clear filters</button>}
</div>
{selected.size > 0 && (
<div className="am-bulkbar" role="region" aria-label="Bulk actions">
<strong>{selected.size} selected</strong>
<select className="am-select am-select--compact" value={bulkMove} disabled={busy} onChange={e => { const f = e.target.value; setBulkMove(f); if (f) void moveAssets(selectedAssets, f) }} aria-label="Move selected to folder">
<option value="">Move selected to…</option>
{MOVE_FOLDERS.map(f => <option key={f} value={f}>{f}/</option>)}
</select>
<button type="button" className="btn-admin-remove" disabled={busy} onClick={() => void deleteAssets(selectedAssets)}>Delete selected</button>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => setSelected(new Set())}>Clear selection</button>
</div>
)}
{assets.length === 0 && <p className="admin-stats-note">No assets uploaded yet.</p>}
{assets.length > 0 && visible.length === 0 && (
<p className="admin-stats-note">No assets match these filters. <button type="button" className="am-link-btn" onClick={clearFilters}>Clear filters</button></p>
)}
{visible.length > 0 && view === 'list' && (
<ul className="am-list">
{visible.map(asset => {
const id = assetId(asset)
return (
<li key={id} className={`am-row${selected.has(id) ? ' is-selected' : ''}`}>
<input type="checkbox" className="am-row-check" checked={selected.has(id)} onChange={() => toggleSelected(id)} aria-label={`Select ${asset.filename}`} />
<a className="am-thumb" href={asset.url} target="_blank" rel="noreferrer" tabIndex={-1}>{renderThumb(asset)}</a>
<div className="am-main">
{renderName(asset)}
{renderMeta(asset)}
</div>
<div className="am-tags-cell">{renderTags(asset)}</div>
<div className="am-actions-cell">{renderActions(asset)}</div>
</li>
)
})}
</ul>
)}
{visible.length > 0 && view === 'grid' && (
<ul className="am-grid">
{visible.map(asset => {
const id = assetId(asset)
return (
<li key={id} className={`am-card${selected.has(id) ? ' is-selected' : ''}`}>
<input type="checkbox" className="am-card-check" checked={selected.has(id)} onChange={() => toggleSelected(id)} aria-label={`Select ${asset.filename}`} />
<a className="am-card-thumb" href={asset.url} target="_blank" rel="noreferrer" tabIndex={-1}>{renderThumb(asset)}</a>
<div className="am-card-body">
{renderName(asset)}
{renderMeta(asset)}
{renderTags(asset)}
{renderActions(asset)}
</div>
</li>
)
})}
</ul>
)}
{message && <p className="admin-stats-note" role="status">{message}</p>}
</section>
)
}
+282
View File
@@ -0,0 +1,282 @@
import { useMemo, useState } from 'react'
export interface InboxSubmission {
id: string
submittedAt: string
name: string
email: string
message: string
messageType: string
subscribe: boolean
archived?: boolean
read?: boolean
source?: 'contact-form' | 'download' | 'inbound-email' | 'resend-import'
inboundTo?: string
htmlBody?: string | null
}
interface InboxPanelProps {
submissions: InboxSubmission[]
setSubmissions: React.Dispatch<React.SetStateAction<InboxSubmission[]>>
}
type Tab = 'inbox' | 'unread' | 'archived' | 'all'
type Kind = 'email' | 'newsletter' | 'download' | 'message'
const TYPE_LABEL: Record<string, string> = {
question: 'Bible Question',
community: 'Community Question',
testimony: 'Testimony',
topic: 'Topic Request',
gospel: 'Gospel Courtroom Question',
general: 'General Message',
}
const KIND_LABEL: Record<Kind, string> = { email: 'Email', newsletter: 'Newsletter signup', download: 'Download request', message: 'Contact form' }
const CP1252: Record<string, number> = {
'€': 0x80, '‚': 0x82, 'ƒ': 0x83, '„': 0x84, '…': 0x85, '†': 0x86, '‡': 0x87, 'ˆ': 0x88, '‰': 0x89, 'Š': 0x8a,
'‹': 0x8b, 'Œ': 0x8c, 'Ž': 0x8e, '‘': 0x91, '’': 0x92, '“': 0x93, '”': 0x94, '•': 0x95, '–': 0x96, '—': 0x97,
'˜': 0x98, '™': 0x99, 'š': 0x9a, '›': 0x9b, 'œ': 0x9c, 'ž': 0x9e, 'Ÿ': 0x9f,
}
// Earlier inbound emails were stored with UTF-8 text decoded as Latin-1 ("’", "é"); undo that for display.
function fixMojibake(text: string) {
if (!/[ÃÂâð][\u0080-ÿ€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ]/.test(text)) return text
const bytes: number[] = []
for (const ch of text) {
const code = ch.codePointAt(0) ?? 0
if (code <= 0xff) bytes.push(code)
else if (CP1252[ch] !== undefined) bytes.push(CP1252[ch])
else return text
}
try {
return new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array(bytes))
} catch {
return text
}
}
function kindOf(s: InboxSubmission): Kind {
if (s.source === 'inbound-email') return 'email'
if (/^newsletter signup/i.test(s.message)) return 'newsletter'
if (s.source === 'download' || /^requested\b/i.test(s.message)) return 'download'
return 'message'
}
function parse(s: InboxSubmission) {
const text = fixMojibake(s.message ?? '')
const m = /^Subject:\s*(.*?)(?:\r?\n\s*\r?\n|\r?\n)([\s\S]*)$/.exec(text)
if (m) return { subject: m[1].trim() || '(no subject)', body: m[2].trim() }
const single = /^Subject:\s*(.*)$/.exec(text)
if (single) return { subject: single[1].trim() || '(no subject)', body: '' }
const [first, ...rest] = text.split(/\r?\n/)
return { subject: first.trim() || '(no subject)', body: rest.join('\n').trim() }
}
function fmt(value: string) {
const d = new Date(value)
return Number.isNaN(d.getTime()) ? '' : d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
}
export default function InboxPanel({ submissions: allSubmissions, setSubmissions }: InboxPanelProps) {
// Subscribers imported from Resend are bookkeeping records, not messages.
const submissions = useMemo(() => allSubmissions.filter(s => s.source !== 'resend-import'), [allSubmissions])
const [tab, setTab] = useState<Tab>('inbox')
const [kind, setKind] = useState<'all' | Kind>('all')
const [search, setSearch] = useState('')
const [sort, setSort] = useState<'newest' | 'oldest' | 'sender'>('newest')
const [openId, setOpenId] = useState<string | null>(null)
const [checked, setChecked] = useState<Set<string>>(new Set())
const [busy, setBusy] = useState(false)
const [formatted, setFormatted] = useState(false)
const [note, setNote] = useState('')
const rows = useMemo(() => submissions.map(s => ({ s, kind: kindOf(s), ...parse(s) })), [submissions])
const unread = submissions.filter(s => !s.archived && !s.read).length
const archivedCount = submissions.filter(s => s.archived).length
const kindCounts = useMemo(() => {
const c: Record<string, number> = {}
for (const r of rows) c[r.kind] = (c[r.kind] ?? 0) + 1
return c
}, [rows])
const visible = useMemo(() => {
const q = search.trim().toLowerCase().split(/\s+/).filter(Boolean)
const list = rows.filter(r => {
const { s } = r
if (tab === 'inbox' && s.archived) return false
if (tab === 'unread' && (s.archived || s.read)) return false
if (tab === 'archived' && !s.archived) return false
if (kind !== 'all' && r.kind !== kind) return false
if (q.length) {
const hay = `${s.name} ${s.email} ${r.subject} ${r.body}`.toLowerCase()
if (!q.every(w => hay.includes(w))) return false
}
return true
})
list.sort((a, b) => {
if (sort === 'sender') return a.s.name.localeCompare(b.s.name)
const d = new Date(a.s.submittedAt).getTime() - new Date(b.s.submittedAt).getTime()
return sort === 'oldest' ? d : -d
})
return list
}, [rows, tab, kind, search, sort])
const open = openId ? rows.find(r => r.s.id === openId) ?? null : null
const visibleIds = visible.map(r => r.s.id)
const allChecked = visible.length > 0 && visibleIds.every(id => checked.has(id))
async function bulk(ids: string[], action: 'archive' | 'unarchive' | 'read' | 'unread' | 'delete') {
if (ids.length === 0) return
if (action === 'delete' && !confirm(`Permanently delete ${ids.length} message${ids.length === 1 ? '' : 's'}? This can’t be undone.`)) return
setBusy(true)
setNote('')
try {
const res = await fetch('/api/admin-contact-submissions/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, action }),
})
if (!res.ok) throw new Error('failed')
const idSet = new Set(ids)
setSubmissions(prev => action === 'delete'
? prev.filter(s => !idSet.has(s.id))
: prev.map(s => !idSet.has(s.id) ? s : action === 'archive' ? { ...s, archived: true } : action === 'unarchive' ? { ...s, archived: false } : { ...s, read: action === 'read' }))
setChecked(prev => { const n = new Set(prev); ids.forEach(id => n.delete(id)); return n })
if (openId && idSet.has(openId) && (action === 'delete' || action === 'archive')) setOpenId(null)
} catch {
setNote('That action failed. Please try again.')
} finally {
setBusy(false)
}
}
function openMessage(id: string) {
setFormatted(false)
if (openId === id) { setOpenId(null); return }
setOpenId(id)
const sub = submissions.find(s => s.id === id)
if (sub && !sub.read) void bulk([id], 'read')
}
const tabs: Array<[Tab, string, number | null]> = [
['inbox', 'Inbox', submissions.length - archivedCount],
['unread', 'Unread', unread],
['archived', 'Archived', archivedCount],
['all', 'All', submissions.length],
]
const selectedIds = [...checked].filter(id => submissions.some(s => s.id === id))
return (
<section className="admin-panel-section ib-root" aria-label="Inbox">
<div className="admin-panel-head">
<h2>Inbox</h2>
<p>{unread} unread · {archivedCount} archived · {submissions.length} total</p>
</div>
<div className="ib-toolbar">
<input type="search" className="ib-search" placeholder="Search sender, subject, or message…" value={search} onChange={e => setSearch(e.target.value)} aria-label="Search inbox" />
<select className="ib-select" value={kind} onChange={e => setKind(e.target.value as 'all' | Kind)} aria-label="Filter by type">
<option value="all">All types</option>
{(Object.keys(KIND_LABEL) as Kind[]).map(k => <option key={k} value={k}>{KIND_LABEL[k]} ({kindCounts[k] ?? 0})</option>)}
</select>
<select className="ib-select" value={sort} onChange={e => setSort(e.target.value as typeof sort)} aria-label="Sort">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="sender">Sender A–Z</option>
</select>
</div>
<div className="ib-tabs" role="tablist">
{tabs.map(([id, label, count]) => (
<button key={id} type="button" role="tab" aria-selected={tab === id} className={`ib-tab${tab === id ? ' is-active' : ''}`} onClick={() => setTab(id)}>
{label}{count !== null && <span className="ib-tab-count">{count}</span>}
</button>
))}
</div>
<div className="ib-bar">
<label className="ib-check">
<input type="checkbox" checked={allChecked} disabled={visible.length === 0} onChange={() => setChecked(prev => { const n = new Set(prev); visibleIds.forEach(id => allChecked ? n.delete(id) : n.add(id)); return n })} />
Select all shown
</label>
<span className="ib-count">{visible.length} shown</span>
{selectedIds.length > 0 && (
<div className="ib-bulk">
<strong>{selectedIds.length} selected</strong>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={busy} onClick={() => void bulk(selectedIds, 'read')}>Mark read</button>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={busy} onClick={() => void bulk(selectedIds, 'unread')}>Mark unread</button>
{tab === 'archived'
? <button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={busy} onClick={() => void bulk(selectedIds, 'unarchive')}>Unarchive</button>
: <button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={busy} onClick={() => void bulk(selectedIds, 'archive')}>Archive</button>}
<button type="button" className="btn-admin-remove" disabled={busy} onClick={() => void bulk(selectedIds, 'delete')}>Delete</button>
</div>
)}
{unread > 0 && tab !== 'archived' && selectedIds.length === 0 && (
<button type="button" className="am-link-btn" disabled={busy} onClick={() => void bulk(submissions.filter(s => !s.archived && !s.read).map(s => s.id), 'read')}>Mark all read</button>
)}
</div>
{note && <p className="admin-stats-note" role="status">{note}</p>}
{submissions.length === 0 && <p className="admin-stats-note">No messages yet.</p>}
{submissions.length > 0 && visible.length === 0 && <p className="admin-stats-note">No messages match these filters.</p>}
<div className={`ib-layout${open ? ' has-open' : ''}`}>
<ul className="ib-list">
{visible.map(r => {
const { s } = r
const isUnread = !s.read && !s.archived
return (
<li key={s.id} className={`ib-item${isUnread ? ' is-unread' : ''}${openId === s.id ? ' is-open' : ''}`}>
<input type="checkbox" checked={checked.has(s.id)} onChange={() => setChecked(prev => { const n = new Set(prev); if (n.has(s.id)) n.delete(s.id); else n.add(s.id); return n })} aria-label={`Select message from ${s.name}`} />
<button type="button" className="ib-item-main" onClick={() => openMessage(s.id)}>
<span className="ib-item-top">
<span className="ib-from">{isUnread && <i className="ib-dot" aria-label="Unread" />}{fixMojibake(s.name)}</span>
<span className="ib-date">{fmt(s.submittedAt)}</span>
</span>
<span className="ib-subject">{r.subject}</span>
{r.body && <span className="ib-preview">{r.body.replace(/\s+/g, ' ').slice(0, 220)}</span>}
<span className="ib-tags">
<span className={`ib-badge ib-badge--${r.kind}`}>{r.kind === 'message' ? (TYPE_LABEL[s.messageType] ?? KIND_LABEL.message) : KIND_LABEL[r.kind]}</span>
{s.archived && <span className="ib-badge">Archived</span>}
<span className="ib-addr">{s.email}</span>
</span>
</button>
</li>
)
})}
</ul>
{open && (
<article className="ib-detail">
<header className="ib-detail-head">
<div className="ib-detail-title">
<h3>{open.subject}</h3>
<p><strong>{fixMojibake(open.s.name)}</strong> &lt;<a href={`mailto:${open.s.email}`}>{open.s.email}</a>&gt;</p>
<p className="ib-detail-sub">
{fmt(open.s.submittedAt)} · {open.kind === 'message' ? (TYPE_LABEL[open.s.messageType] ?? KIND_LABEL.message) : KIND_LABEL[open.kind]}
{open.s.inboundTo ? ` · to ${open.s.inboundTo}` : ''}
{open.s.subscribe ? ' · subscribed' : ''}
</p>
</div>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => setOpenId(null)} aria-label="Close message">✕</button>
</header>
<div className="ib-detail-actions">
<a className="btn-admin-save" href={`mailto:${open.s.email}?subject=${encodeURIComponent(/^re:/i.test(open.subject) ? open.subject : `Re: ${open.subject}`)}`}>Reply</a>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={busy} onClick={() => void bulk([open.s.id], open.s.archived ? 'unarchive' : 'archive')}>{open.s.archived ? 'Unarchive' : 'Archive'}</button>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={busy} onClick={() => void bulk([open.s.id], 'unread')}>Mark unread</button>
<button type="button" className="btn-admin-remove" disabled={busy} onClick={() => void bulk([open.s.id], 'delete')}>Delete</button>
{open.s.htmlBody && (
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => setFormatted(f => !f)}>{formatted ? 'Show plain text' : 'Show formatted'}</button>
)}
</div>
{formatted && open.s.htmlBody
? <iframe className="ib-frame" title="Formatted email" sandbox="" srcDoc={open.s.htmlBody} />
: <div className="ib-body">{open.body || open.subject}</div>}
</article>
)}
</div>
</section>
)
}
+45 -18
View File
@@ -2,11 +2,41 @@ import { useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
const CONSENT_KEY = 'vbn_analytics_consent_choice' const CONSENT_KEY = 'vbn_analytics_consent_choice'
const UTM_PENDING_KEY = 'vbn_pending_utm'
function hasConsent(): boolean { function hasConsent(): boolean {
return localStorage.getItem(CONSENT_KEY) === 'accepted' return localStorage.getItem(CONSENT_KEY) === 'accepted'
} }
// Capture UTM params immediately on module load, before consent is given.
// They're held in sessionStorage so they survive a consent-banner accept.
;(function captureUtmEarly() {
try {
const params = new URLSearchParams(window.location.search)
const utm: Record<string, string> = {}
for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']) {
const val = params.get(key)
if (val) utm[key] = val
}
if (Object.keys(utm).length > 0) {
sessionStorage.setItem(UTM_PENDING_KEY, JSON.stringify({ path: window.location.pathname, ...utm }))
}
} catch { /* sessionStorage unavailable */ }
})()
// The client gates on a localStorage flag; the server gates on a cookie.
// If the cookie is lost (cleared separately, dropped by the browser on a
// plain-HTTP deployment, etc.) while localStorage still says "accepted",
// the client would otherwise keep believing tracking is on and silently
// lose every event forever. Self-heal by clearing the stale flag so the
// consent banner reappears instead of leaking data indefinitely.
export async function checkAnalyticsConsentResponse(res: Response): Promise<void> {
try {
const data = await res.json()
if (data?.reason === 'no-consent') localStorage.removeItem(CONSENT_KEY)
} catch { /* ignore */ }
}
export function sendEvent(type: string, data: Record<string, unknown>): void { export function sendEvent(type: string, data: Record<string, unknown>): void {
if (!hasConsent()) return if (!hasConsent()) return
fetch('/api/analytics/event', { fetch('/api/analytics/event', {
@@ -14,7 +44,7 @@ export function sendEvent(type: string, data: Record<string, unknown>): void {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, ...data }), body: JSON.stringify({ type, ...data }),
keepalive: true, keepalive: true,
}).catch(() => {}) }).then(checkAnalyticsConsentResponse).catch(() => {})
} }
// --- Scroll depth --- // --- Scroll depth ---
@@ -45,7 +75,7 @@ export function useScrollDepthTracking() {
// --- Time on page --- // --- Time on page ---
export function useTimeOnPage() { export function useTimeOnPage() {
const location = useLocation() const location = useLocation()
const enteredAt = useRef(Date.now()) const enteredAt = useRef(0)
const path = useRef(location.pathname) const path = useRef(location.pathname)
useEffect(() => { useEffect(() => {
@@ -69,22 +99,21 @@ export function useTimeOnPage() {
}, [location.pathname]) }, [location.pathname])
} }
// --- UTM capture (runs once per page load) --- // --- UTM capture ---
// UTM params are captured into sessionStorage before consent (see top of file).
// This hook drains sessionStorage and sends the event once consent is present.
// Running without a dep array so it retries after the consent banner triggers a re-render.
export function useUTMCapture() { export function useUTMCapture() {
const captured = useRef(false)
useEffect(() => { useEffect(() => {
if (captured.current || !hasConsent()) return if (!hasConsent()) return
const params = new URLSearchParams(window.location.search) try {
const utm: Record<string, string> = {} const raw = sessionStorage.getItem(UTM_PENDING_KEY)
for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']) { if (!raw) return
const val = params.get(key) const utm = JSON.parse(raw) as Record<string, string>
if (val) utm[key] = val sessionStorage.removeItem(UTM_PENDING_KEY)
} sendEvent('utm', utm)
if (Object.keys(utm).length === 0) return } catch { /* ignore */ }
captured.current = true })
sendEvent('utm', { path: window.location.pathname, ...utm })
}, [])
} }
// --- All link clicks (internal + external) --- // --- All link clicks (internal + external) ---
@@ -125,5 +154,3 @@ export function useLinkClickTracking() {
}, []) }, [])
} }
/** @deprecated Use useLinkClickTracking instead */
export function useOutboundLinkTracking() { useLinkClickTracking() }
File diff suppressed because it is too large. Load diff
+284
View File
@@ -0,0 +1,284 @@
import { useEffect, useState } from 'react'
interface Trend { thisWeek: number; lastWeek: number; series: number[] }
interface ResendStatus { available: boolean; reason?: string; broadcast?: { id: string; name: string; subject: string; sentAt: string | null; dashboardUrl: string } | null }
export interface DashboardData {
seasonCheck: { total: number; errors: number; warnings: number; general: number; lanes: { lane: string; title: string; season: string; errors: number; warnings: number }[] } | null
trends: { visits: Trend; subscribers: Trend; studySignups: Trend }
traffic: { topPages: { path: string; hits: number }[]; topReferrers: { referrer: string; count: number }[] }
reengagement: { t1: number; t2: number; t3: number; recentlySent: number; remindersEnabled: number }
studyProgress: {
studies: { slug: string; title: string; enrolled: number; lessonsCompleted: number; certificates: number }[]
certificatesTotal: number
lastActivityAt: string | null
}
latestEpisode: { available: boolean; title?: string; episode?: string; season?: string; publishedAt?: string | null; daysAgo?: number | null; stale?: boolean; staleAfterDays?: number }
nuggets: { uploadsThisWeek: number; pending: number; latest: { hostName: string; episodeName: string; uploadedAt: string }[] }
health: {
resendConfigured: boolean
lastBackup: { ok: boolean; at: string | null; error: string | null; file: string | null }
backupCount: number | null
dataDirWritable: boolean | null
writes: { hitStats: { ok: boolean; at: string | null }; visitorStats: { ok: boolean; at: string | null } }
serverStartedAt: string
}
lastBroadcast: ResendStatus
comments: { pending: number; thisWeek: number }
activity: { at: string; kind: string; text: string }[]
}
interface Props {
publishedAt: string | null
draftUpdatedAt: string | null
isDirty: boolean
onNavigate: (view: string) => void
onRequestPublish: () => void
}
function Sparkline({ values }: { values: number[] }) {
const max = Math.max(1, ...values)
const step = 100 / Math.max(1, values.length - 1)
const points = values.map((v, i) => `${(i * step).toFixed(1)},${(22 - (v / max) * 20).toFixed(1)}`).join(' ')
return (
<svg className="admin-dashboard-spark" viewBox="0 0 100 24" preserveAspectRatio="none" aria-hidden="true">
<polyline points={points} fill="none" stroke="currentColor" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
</svg>
)
}
function TrendCard({ label, trend, onClick, actionLabel }: { label: string; trend: Trend; onClick?: () => void; actionLabel?: string }) {
const { thisWeek, lastWeek } = trend
const diff = thisWeek - lastWeek
const pct = lastWeek > 0 ? Math.round((diff / lastWeek) * 100) : null
const tone = diff > 0 ? 'up' : diff < 0 ? 'down' : 'flat'
const arrow = diff > 0 ? '▲' : diff < 0 ? '▼' : '–'
const delta = pct !== null ? `${arrow} ${Math.abs(pct)}%` : diff > 0 ? `${arrow} new` : '–'
return (
<div className="admin-dashboard-card">
<div className="admin-dashboard-card-value">{thisWeek.toLocaleString()}</div>
<div className="admin-dashboard-card-label">{label}</div>
<div className="admin-dashboard-card-sub">
<span className={`admin-dashboard-delta admin-dashboard-delta--${tone}`}>{delta}</span> vs {lastWeek.toLocaleString()} previous 7 days
</div>
<Sparkline values={trend.series} />
{onClick && <button type="button" className="admin-dashboard-card-action" onClick={onClick}>{actionLabel} →</button>}
</div>
)
}
function formatWhen(value: string | null | undefined) {
if (!value) return '—'
const d = new Date(value)
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
export function AdminDashboardExtras({ publishedAt, draftUpdatedAt, isDirty, onNavigate, onRequestPublish }: Props) {
const [data, setData] = useState<DashboardData | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
let cancelled = false
fetch('/api/admin-dashboard')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load dashboard data'))))
.then(json => { if (!cancelled) setData(json as DashboardData) })
.catch(() => { if (!cancelled) setFailed(true) })
return () => { cancelled = true }
}, [])
const draftNewer = !!draftUpdatedAt && (!publishedAt || new Date(draftUpdatedAt).getTime() > new Date(publishedAt).getTime())
const unpublished = isDirty || draftNewer
const attention: { key: string; label: string; tone: 'alert' | 'ok'; action?: () => void; actionLabel?: string }[] = []
if (data) {
if (data.seasonCheck && data.seasonCheck.total > 0) attention.push({ key: 'season-check', label: `Season check: ${data.seasonCheck.total} thing${data.seasonCheck.total === 1 ? '' : 's'} to fix`, tone: 'alert', action: () => onNavigate('podcast:season-check'), actionLabel: 'Open Season Check' })
if (data.comments.pending > 0) attention.push({ key: 'comments', label: `${data.comments.pending} study comment${data.comments.pending === 1 ? '' : 's'} awaiting approval`, tone: 'alert', action: () => onNavigate('study-comments'), actionLabel: 'Review' })
if (data.nuggets.pending > 0) attention.push({ key: 'nuggets', label: `${data.nuggets.pending} Nuggets upload${data.nuggets.pending === 1 ? '' : 's'} waiting in the workflow`, tone: 'alert', action: () => onNavigate('nuggets:workflow'), actionLabel: 'Open workflow' })
if (data.latestEpisode.available && data.latestEpisode.stale) attention.push({ key: 'feed', label: `Podcast feed has no new episode in ${data.latestEpisode.daysAgo} days`, tone: 'alert', action: () => onNavigate('podcast:rss'), actionLabel: 'Check feed' })
if (!data.health.lastBackup.ok) attention.push({ key: 'backup', label: 'Last data backup failed', tone: 'alert' })
if (data.health.dataDirWritable === false) attention.push({ key: 'disk', label: 'Data directory is not writable', tone: 'alert' })
}
return (
<>
{unpublished && (
<div className="admin-dashboard-banner" role="status">
<div>
<strong>{isDirty ? 'You have unsaved edits.' : 'Draft changes are not live yet.'}</strong>
<span> Draft updated {formatWhen(draftUpdatedAt)} · Live version published {formatWhen(publishedAt)}.</span>
</div>
<button type="button" className="btn-admin-save" onClick={onRequestPublish}>Publish…</button>
</div>
)}
<div className="admin-dashboard-quick">
<span className="admin-dashboard-quick-label">Quick actions</span>
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate('broadcast')}>New Broadcast</button>
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate('downloads')}>Add Resource</button>
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate('qr-codes')}>QR Code</button>
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate('questions')}>Questions</button>
<button type="button" className="btn-admin-ghost" onClick={onRequestPublish}>Publish</button>
</div>
{failed && <p className="admin-stats-note">Couldn&apos;t load the extra dashboard insights.</p>}
{!data && !failed && <p className="admin-stats-note">Loading insights…</p>}
{data && (
<>
{attention.length > 0 && (
<div className="admin-dashboard-attention">
<h3 className="admin-dashboard-activity-heading">Needs attention</h3>
<ul>
{attention.map(item => (
<li key={item.key}>
<span>{item.label}</span>
{item.action && <button type="button" className="admin-dashboard-card-action" style={{ marginTop: 0 }} onClick={item.action}>{item.actionLabel} →</button>}
</li>
))}
</ul>
</div>
)}
{data.seasonCheck && data.seasonCheck.lanes.length > 0 && (
<>
<h3 className="admin-dashboard-section-title">Season check</h3>
<div className="admin-dashboard-grid">
{data.seasonCheck.lanes.map(l => {
const issues = l.errors + l.warnings
return (
<div key={l.lane} className="admin-dashboard-card">
<div className="admin-dashboard-card-value">{issues === 0 ? '✓' : issues}</div>
<div className="admin-dashboard-card-label">Lane {l.lane}{l.title ? ` — ${l.title}` : ''}</div>
<div className="admin-dashboard-card-sub">
{l.season ? `Season ${l.season}` : 'No season number set'} · {issues === 0 ? 'all set' : `${issues} to fix`}
</div>
<button type="button" className="admin-dashboard-card-action" onClick={() => onNavigate('podcast:season-check')}>Season Check →</button>
</div>
)
})}
</div>
{data.seasonCheck.general > 0 && (
<p className="admin-stats-note">Plus {data.seasonCheck.general} thing{data.seasonCheck.general === 1 ? '' : 's'} that apply to every lane (such as scripts with no season number).</p>
)}
</>
)}
<h3 className="admin-dashboard-section-title">Trends</h3>
<div className="admin-dashboard-grid">
<TrendCard label="Real visits (7 days)" trend={data.trends.visits} onClick={() => onNavigate('analytics')} actionLabel="Analytics" />
<TrendCard label="New subscribers" trend={data.trends.subscribers} onClick={() => onNavigate('subscribers')} actionLabel="Subscribers" />
<TrendCard label="New study accounts" trend={data.trends.studySignups} onClick={() => onNavigate('study-users')} actionLabel="Study users" />
<div className="admin-dashboard-card">
<div className="admin-dashboard-card-value">{data.comments.thisWeek}</div>
<div className="admin-dashboard-card-label">Study comments (7 days)</div>
<div className="admin-dashboard-card-sub">{data.comments.pending} awaiting approval</div>
<button type="button" className="admin-dashboard-card-action" onClick={() => onNavigate('study-comments')}>Study comments →</button>
</div>
</div>
<div className="admin-dashboard-activity">
<div className="admin-dashboard-activity-col">
<h3 className="admin-dashboard-activity-heading">Top pages (7 days)</h3>
{data.traffic.topPages.length === 0 ? <p className="admin-stats-note">No visits recorded yet.</p> : (
<div className="admin-dashboard-feed">
{data.traffic.topPages.map(p => (
<div key={p.path} className="admin-dashboard-feed-item admin-dashboard-row"><span>{p.path}</span><strong>{p.hits}</strong></div>
))}
</div>
)}
</div>
<div className="admin-dashboard-activity-col">
<h3 className="admin-dashboard-activity-heading">Top referrers (7 days)</h3>
{data.traffic.topReferrers.length === 0 ? <p className="admin-stats-note">No referrers recorded yet.</p> : (
<div className="admin-dashboard-feed">
{data.traffic.topReferrers.map(r => (
<div key={r.referrer} className="admin-dashboard-feed-item admin-dashboard-row"><span>{r.referrer}</span><strong>{r.count}</strong></div>
))}
</div>
)}
</div>
</div>
<div className="admin-dashboard-activity" style={{ marginTop: '1rem' }}>
<div className="admin-dashboard-activity-col">
<h3 className="admin-dashboard-activity-heading">Last broadcast</h3>
{!data.lastBroadcast.available ? <p className="admin-stats-note">{data.lastBroadcast.reason ?? 'Resend is unavailable.'}</p>
: !data.lastBroadcast.broadcast ? <p className="admin-stats-note">No broadcasts sent yet.</p> : (
<div className="admin-dashboard-feed-item">
<div className="admin-dashboard-feed-meta"><strong>{data.lastBroadcast.broadcast.subject || data.lastBroadcast.broadcast.name}</strong></div>
<div className="admin-dashboard-feed-email">Sent {formatWhen(data.lastBroadcast.broadcast.sentAt)}</div>
<a className="admin-dashboard-card-action" href={data.lastBroadcast.broadcast.dashboardUrl} target="_blank" rel="noreferrer">Opens, clicks &amp; unsubscribes in Resend →</a>
</div>
)}
</div>
<div className="admin-dashboard-activity-col">
<h3 className="admin-dashboard-activity-heading">Study re-engagement</h3>
<div className="admin-dashboard-feed">
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Inactive 7–13 days</span><strong>{data.reengagement.t1}</strong></div>
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Inactive 14–29 days</span><strong>{data.reengagement.t2}</strong></div>
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Inactive 30+ days</span><strong>{data.reengagement.t3}</strong></div>
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Reminders sent (30 days)</span><strong>{data.reengagement.recentlySent}</strong></div>
<div className="admin-dashboard-card-sub">{data.reengagement.remindersEnabled} students have reminders on</div>
</div>
</div>
</div>
<div className="admin-dashboard-activity" style={{ marginTop: '1rem' }}>
<div className="admin-dashboard-activity-col">
<h3 className="admin-dashboard-activity-heading">Study progress</h3>
{data.studyProgress.studies.length === 0 ? <p className="admin-stats-note">No study activity yet.</p> : (
<div className="admin-dashboard-feed">
{data.studyProgress.studies.map(s => (
<div key={s.slug} className="admin-dashboard-feed-item">
<div className="admin-dashboard-feed-meta"><strong>{s.title}</strong></div>
<div className="admin-dashboard-feed-preview">{s.enrolled} enrolled · {s.lessonsCompleted} lessons completed · {s.certificates} certificate{s.certificates === 1 ? '' : 's'}</div>
</div>
))}
<div className="admin-dashboard-card-sub">Last study activity: {formatWhen(data.studyProgress.lastActivityAt)}</div>
</div>
)}
</div>
<div className="admin-dashboard-activity-col">
<h3 className="admin-dashboard-activity-heading">Latest podcast episode</h3>
{!data.latestEpisode.available ? <p className="admin-stats-note">Couldn&apos;t read the podcast feed.</p> : (
<div className="admin-dashboard-feed-item">
<div className="admin-dashboard-feed-meta"><strong>{data.latestEpisode.title}</strong></div>
<div className="admin-dashboard-feed-email">
{data.latestEpisode.season ? `S${data.latestEpisode.season} ` : ''}{data.latestEpisode.episode ? `E${data.latestEpisode.episode} · ` : ''}
{formatWhen(data.latestEpisode.publishedAt)}{data.latestEpisode.daysAgo != null ? ` (${data.latestEpisode.daysAgo} days ago)` : ''}
</div>
{data.latestEpisode.stale && <div className="admin-dashboard-stale">No new episode in over {data.latestEpisode.staleAfterDays} days.</div>}
</div>
)}
</div>
</div>
<div className="admin-dashboard-activity" style={{ marginTop: '1rem' }}>
<div className="admin-dashboard-activity-col">
<h3 className="admin-dashboard-activity-heading">System health</h3>
<div className="admin-dashboard-feed">
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Resend API key</span><strong>{data.health.resendConfigured ? 'Configured' : 'Missing'}</strong></div>
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Last backup</span><strong>{data.health.lastBackup.at ? `${data.health.lastBackup.ok ? '✓' : '✗'} ${formatWhen(data.health.lastBackup.at)}` : 'None this run'}</strong></div>
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Backups on disk</span><strong>{data.health.backupCount ?? '—'}</strong></div>
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Data directory</span><strong>{data.health.dataDirWritable === null ? '—' : data.health.dataDirWritable ? 'Writable' : 'Not writable'}</strong></div>
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Server started</span><strong>{formatWhen(data.health.serverStartedAt)}</strong></div>
</div>
</div>
<div className="admin-dashboard-activity-col">
<h3 className="admin-dashboard-activity-heading">Recent activity</h3>
{data.activity.length === 0 ? <p className="admin-stats-note">No activity yet.</p> : (
<div className="admin-dashboard-feed">
{data.activity.map((item, i) => (
<div key={`${item.at}-${i}`} className="admin-dashboard-feed-item admin-dashboard-row">
<span>{item.text}</span><span className="admin-dashboard-feed-date">{formatWhen(item.at)}</span>
</div>
))}
</div>
)}
</div>
</div>
</>
)}
</>
)
}
+215 -191
View File
@@ -27,11 +27,13 @@ ChartJS.register(
ArcElement, ArcElement,
) )
type StatsRange = '7d' | '30d' | '90d' | 'all'
interface Props { interface Props {
stats: AdminStats | null stats: AdminStats | null
statsStatus: 'loading' | 'error' | 'ready' statsStatus: 'loading' | 'error' | 'ready'
statsRange?: '7d' | '30d' | '90d' statsRange?: StatsRange
onRangeChange?: (range: '7d' | '30d' | '90d') => void onRangeChange?: (range: StatsRange) => void
opsStatus: { buildCommit: string | null; buildNumber: string | null; deployedAt: string | null; cachePurge: { ok: boolean; at: string | null; error: string | null }; deployHook: { ok: boolean; at: string | null; error: string | null } } | null opsStatus: { buildCommit: string | null; buildNumber: string | null; deployedAt: string | null; cachePurge: { ok: boolean; at: string | null; error: string | null }; deployHook: { ok: boolean; at: string | null; error: string | null } } | null
formatDate: (date: string | null) => string formatDate: (date: string | null) => string
maskIp: (ip: string) => string maskIp: (ip: string) => string
@@ -53,6 +55,37 @@ interface Props {
onRefreshStatus: () => void onRefreshStatus: () => void
} }
const RANGE_LABELS: Record<StatsRange, string> = {
'7d': 'Last 7 days',
'30d': 'Last 30 days',
'90d': 'Last 90 days',
'all': 'All time',
}
function fmtSec(s: number) {
if (s >= 3600) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
if (s >= 60) return `${Math.floor(s / 60)}m ${s % 60}s`
return `${s}s`
}
// Aggregate daily data into weekly buckets when there are many points (all-time)
function aggregateTrend(data: Array<{ day: string; real: number; bot: number }>) {
if (data.length <= 60) return data
// Group by ISO week (YYYY-Www) — bucket by ~7 days
const buckets: Record<string, { day: string; real: number; bot: number }> = {}
for (const d of data) {
const date = new Date(d.day)
// Bucket key: year + week number
const startOfYear = new Date(date.getFullYear(), 0, 1)
const week = Math.ceil(((date.getTime() - startOfYear.getTime()) / 86400000 + startOfYear.getDay() + 1) / 7)
const key = `${date.getFullYear()}-W${String(week).padStart(2, '0')}`
if (!buckets[key]) buckets[key] = { day: d.day, real: 0, bot: 0 }
buckets[key].real += d.real
buckets[key].bot += d.bot
}
return Object.values(buckets)
}
export function AnalyticsPanel({ export function AnalyticsPanel({
stats, stats,
statsStatus, statsStatus,
@@ -60,7 +93,6 @@ export function AnalyticsPanel({
onRangeChange, onRangeChange,
opsStatus, opsStatus,
formatDate, formatDate,
maskIp,
maintenanceMsg, maintenanceMsg,
selectedBackup, selectedBackup,
backupFiles, backupFiles,
@@ -90,7 +122,6 @@ export function AnalyticsPanel({
// Normalize fields that may be missing from older data // Normalize fields that may be missing from older data
const realHits = stats.realHits ?? 0 const realHits = stats.realHits ?? 0
const botHits = stats.botHits ?? 0 const botHits = stats.botHits ?? 0
const totalHits = stats.totalHits ?? 0
const last7DaysReal = stats.last7DaysReal ?? stats.last7Days.map((d: { day: string; hits: number }) => ({ day: d.day, hits: 0 })) const last7DaysReal = stats.last7DaysReal ?? stats.last7Days.map((d: { day: string; hits: number }) => ({ day: d.day, hits: 0 }))
const last7DaysBot = stats.last7DaysBot ?? stats.last7Days.map((d: { day: string; hits: number }) => ({ day: d.day, hits: 0 })) const last7DaysBot = stats.last7DaysBot ?? stats.last7Days.map((d: { day: string; hits: number }) => ({ day: d.day, hits: 0 }))
const last30DaysRealTotal = stats.last30DaysRealTotal ?? 0 const last30DaysRealTotal = stats.last30DaysRealTotal ?? 0
@@ -99,51 +130,46 @@ export function AnalyticsPanel({
const topPathsBot = stats.topPathsBot ?? [] const topPathsBot = stats.topPathsBot ?? []
const botReasons = stats.botReasons ?? [] const botReasons = stats.botReasons ?? []
// Chart data - Daily trend const rangeTotalHits = (stats as AdminStats & { rangeTotalHits?: number }).rangeTotalHits ?? stats.totalHits
const dailyChartData = { const rangeLabel = (stats as AdminStats & { rangeLabel?: string }).rangeLabel ?? RANGE_LABELS[statsRange]
labels: stats.last7Days.map((d: { day: string; hits: number }) => d.day), const rawTrendData = (stats as AdminStats & { trendData?: Array<{ day: string; real: number; bot: number }> }).trendData
// Fall back to last7Days format if trendData not present (old API)
const trendPoints = rawTrendData
? aggregateTrend(rawTrendData)
: last7DaysReal.map((d: { day: string; hits: number }, i: number) => ({
day: d.day,
real: d.hits,
bot: last7DaysBot[i]?.hits ?? 0,
}))
const trendTitle = `Traffic Trend — ${rangeLabel}`
const trendLabels = trendPoints.map(d => d.day.length === 10 ? d.day.slice(5) : d.day)
// Trend chart data
const trendChartData = {
labels: trendLabels,
datasets: [ datasets: [
{ {
label: 'Real Visitors', label: 'Real Visitors',
data: last7DaysReal.map((d: { day: string; hits: number }) => d.hits), data: trendPoints.map(d => d.real),
borderColor: '#c9a84c', borderColor: '#c9a84c',
backgroundColor: 'rgba(201, 168, 76, 0.1)', backgroundColor: 'rgba(201, 168, 76, 0.1)',
tension: 0.4, tension: 0.3,
fill: true, fill: true,
pointRadius: trendPoints.length > 90 ? 0 : 2,
}, },
{ {
label: 'Bot Traffic', label: 'Bot Traffic',
data: last7DaysBot.map((d: { day: string; hits: number }) => d.hits), data: trendPoints.map(d => d.bot),
borderColor: '#666', borderColor: '#666',
backgroundColor: 'rgba(102, 102, 102, 0.1)', backgroundColor: 'rgba(102, 102, 102, 0.1)',
tension: 0.4, tension: 0.3,
fill: true, fill: true,
pointRadius: trendPoints.length > 90 ? 0 : 2,
}, },
], ],
} }
// Chart data - Bot breakdown (pie)
const botPieData = {
labels: botReasons.map((b: { reason: string; count: number }) => b.reason.replace(/-/g, ' ')),
datasets: [{
data: botReasons.map((b: { reason: string; count: number }) => b.count),
backgroundColor: [
'#c9a84c',
'#a0853d',
'#6d5a2e',
'#8b7635',
'#9e8843',
'#bbb477',
'#7a6930',
'#5a4820',
'#c0a060',
'#746c36',
],
borderColor: '#1a1a15',
borderWidth: 2,
}],
}
// Traffic ratio (doughnut) // Traffic ratio (doughnut)
const trafficRatioData = { const trafficRatioData = {
labels: ['Real Visitors', 'Bot Traffic'], labels: ['Real Visitors', 'Bot Traffic'],
@@ -155,44 +181,30 @@ export function AnalyticsPanel({
}], }],
} }
// Top paths comparison // Bot breakdown (pie)
const botPieData = {
labels: botReasons.map((b: { reason: string; count: number }) => b.reason.replace(/-/g, ' ')),
datasets: [{
data: botReasons.map((b: { reason: string; count: number }) => b.count),
backgroundColor: ['#c9a84c','#a0853d','#6d5a2e','#8b7635','#9e8843','#bbb477','#7a6930','#5a4820','#c0a060','#746c36'],
borderColor: '#1a1a15',
borderWidth: 2,
}],
}
// Top paths
const topPathsLabels = Array.from({ length: Math.max(topPathsReal.length, topPathsBot.length) }, (_, i: number) => { const topPathsLabels = Array.from({ length: Math.max(topPathsReal.length, topPathsBot.length) }, (_, i: number) => {
const realPath = topPathsReal[i]?.path || '' return topPathsReal[i]?.path || topPathsBot[i]?.path || `Path ${i + 1}`
const botPath = topPathsBot[i]?.path || ''
return realPath || botPath || `Path ${i + 1}`
}).slice(0, 8) }).slice(0, 8)
const topPathsChartData = { const topPathsChartData = {
labels: topPathsLabels, labels: topPathsLabels,
datasets: [ datasets: [
{ { label: 'Real Hits', data: topPathsLabels.map((_l, i) => topPathsReal[i]?.hits || 0), backgroundColor: '#c9a84c' },
label: 'Real Hits', { label: 'Bot Hits', data: topPathsLabels.map((_l, i) => topPathsBot[i]?.hits || 0), backgroundColor: '#999' },
data: topPathsLabels.map((_label, i) => topPathsReal[i]?.hits || 0),
backgroundColor: '#c9a84c',
},
{
label: 'Bot Hits',
data: topPathsLabels.map((_label, i) => topPathsBot[i]?.hits || 0),
backgroundColor: '#999',
},
], ],
} }
// 30-day trend chart
const last30DaysReal = stats.visitors.last30DaysReal ?? []
const thirtyDayChartData = {
labels: last30DaysReal.map((d: { day: string; hits: number }) => d.day.slice(5)),
datasets: [{
label: 'Real Visitors',
data: last30DaysReal.map((d: { day: string; hits: number }) => d.hits),
borderColor: '#c9a84c',
backgroundColor: 'rgba(201, 168, 76, 0.08)',
tension: 0.3,
fill: true,
pointRadius: 2,
}],
}
// Device breakdown (doughnut) // Device breakdown (doughnut)
const deviceBreakdown = stats.visitors.deviceBreakdown ?? { mobile: 0, desktop: 0, tablet: 0, unknown: 0 } const deviceBreakdown = stats.visitors.deviceBreakdown ?? { mobile: 0, desktop: 0, tablet: 0, unknown: 0 }
const deviceChartData = { const deviceChartData = {
@@ -209,41 +221,22 @@ export function AnalyticsPanel({
responsive: true, responsive: true,
maintainAspectRatio: true, maintainAspectRatio: true,
plugins: { plugins: {
legend: { legend: { labels: { color: '#f0ead8', font: { size: 12 } } },
labels: { tooltip: { backgroundColor: '#2a2518', titleColor: '#f0ead8', bodyColor: '#f0ead8', borderColor: '#c9a84c', borderWidth: 1 },
color: '#f0ead8',
font: { size: 12 },
},
},
tooltip: {
backgroundColor: '#2a2518',
titleColor: '#f0ead8',
bodyColor: '#f0ead8',
borderColor: '#c9a84c',
borderWidth: 1,
},
}, },
scales: { scales: {
y: { y: { beginAtZero: true, ticks: { color: '#7a7060' }, grid: { color: '#2a2518' } },
beginAtZero: true, x: { ticks: { color: '#7a7060', maxTicksLimit: 12 }, grid: { color: '#2a2518' } },
ticks: { color: '#7a7060' },
grid: { color: '#2a2518' },
},
x: {
ticks: { color: '#7a7060' },
grid: { color: '#2a2518' },
},
}, },
} }
const rangeTotalHits = (stats as AdminStats & { rangeTotalHits?: number }).rangeTotalHits ?? totalHits const rangeRealVisitors = stats.visitors.recentVisits.length
const rangeLabel = (stats as AdminStats & { rangeLabel?: string }).rangeLabel ?? statsRange
return ( return (
<section className="admin-panel-section" aria-label="Analytics"> <section className="admin-panel-section" aria-label="Analytics">
<div className="admin-panel-head"> <div className="admin-panel-head">
<h2>Analytics Dashboard</h2> <h2>Analytics Dashboard</h2>
<p>Real-time insights into site traffic, bot detection, visitor behavior, and data management.</p> <p>Site traffic, bot detection, visitor behavior, and data management.</p>
</div> </div>
{/* Top-level panel tabs */} {/* Top-level panel tabs */}
@@ -254,10 +247,10 @@ export function AnalyticsPanel({
</div> </div>
{panelTab === 'traffic' && (<> {panelTab === 'traffic' && (<>
{/* Time-range tabs */} {/* Time-range selector */}
{onRangeChange && ( {onRangeChange && (
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
{(['7d', '30d', '90d'] as const).map(r => ( {(['7d', '30d', '90d', 'all'] as StatsRange[]).map(r => (
<button <button
key={r} key={r}
type="button" type="button"
@@ -274,47 +267,59 @@ export function AnalyticsPanel({
transition: 'all 0.15s', transition: 'all 0.15s',
}} }}
> >
{r === '7d' ? 'Last 7 days' : r === '30d' ? 'Last 30 days' : 'Last 90 days'} {RANGE_LABELS[r]}
</button> </button>
))} ))}
<span style={{ color: '#5a5440', fontSize: '0.8rem', alignSelf: 'center', marginLeft: '0.25rem' }}>
Showing: {rangeLabel}
</span>
</div> </div>
)} )}
{/* KPI Grid */} {/* KPI Grid — split into two clearly-labeled groups so a number from
<div className="admin-stats-grid"> the selected range is never mistaken for an all-time total, or
vice versa. Previously all six cards sat in one grid with three
different implicit time windows (selected range, a hardcoded last
30 days, and all-time), which read as contradictory when e.g. "7
days" was selected but a "Last 30 Days" card sat right next to it. */}
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>{rangeLabel}</h3>
<div className="admin-stats-grid" style={{ marginBottom: '1.5rem' }}>
<article> <article>
<h3>Total Hits</h3> <h3>Real Hits</h3>
<p>{rangeTotalHits.toLocaleString()}</p>
<small>{rangeLabel} all requests</small>
</article>
<article>
<h3>Real Traffic</h3>
<p>{realHits.toLocaleString()}</p> <p>{realHits.toLocaleString()}</p>
<small>{rangeTotalHits > 0 ? ((realHits / rangeTotalHits) * 100).toFixed(1) : '0.0'}% of total</small> <small>Human traffic</small>
</article> </article>
<article> <article>
<h3>Bot Traffic</h3> <h3>Bot Traffic</h3>
<p>{botHits.toLocaleString()}</p> <p>{botHits.toLocaleString()}</p>
<small>{rangeTotalHits > 0 ? ((botHits / rangeTotalHits) * 100).toFixed(1) : '0.0'}% of total</small> <small>{rangeTotalHits > 0 ? `${((botHits / rangeTotalHits) * 100).toFixed(1)}% of total` : '0% of total'}</small>
</article> </article>
<article> <article>
<h3>Last 30 Days</h3> <h3>Total Hits</h3>
<p>{last30DaysRealTotal.toLocaleString()}</p> <p>{rangeTotalHits.toLocaleString()}</p>
<small>Real hits • {last30DaysBotTotal.toLocaleString()} bots</small> <small>Real + bot</small>
</article>
</div>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>All-Time</h3>
<div className="admin-stats-grid">
<article>
<h3>All-Time Real Hits</h3>
<p>{((stats as AdminStats & { allTimeRealHits?: number }).allTimeRealHits ?? stats.totalHits).toLocaleString()}</p>
<small>Since {formatDate(stats.firstHitAt)}</small>
</article> </article>
<article> <article>
<h3>Unique Visitors</h3> <h3>Unique Visitors</h3>
<p>{stats.visitors.uniqueVisitors.toLocaleString()}</p> <p>{stats.visitors.uniqueVisitors.toLocaleString()}</p>
<small>Tracked with consent</small> <small>{stats.uniqueVisitors7d != null ? `${stats.uniqueVisitors7d.toLocaleString()} this week · ` : ''}Tracked with consent</small>
</article> </article>
<article> <article>
<h3>Returning Rate</h3> <h3>Returning Rate</h3>
<p>{stats.visitors.totalVisits > 0 ? `${Math.round((stats.visitors.returningVisits / stats.visitors.totalVisits) * 100)}%` : '0%'}</p> <p>{stats.visitors.totalVisits > 0 ? `${Math.round((stats.visitors.returningVisits / stats.visitors.totalVisits) * 100)}%` : '0%'}</p>
<small>{stats.visitors.returningVisits.toLocaleString()} return visits</small> <small>{stats.visitors.returningVisits.toLocaleString()} return visits</small>
</article> </article>
<article>
<h3>Last 30 Days</h3>
<p>{last30DaysRealTotal.toLocaleString()}</p>
<small>Real hits · {last30DaysBotTotal.toLocaleString()} bots</small>
</article>
</div> </div>
{/* Chart Tabs */} {/* Chart Tabs */}
@@ -329,19 +334,23 @@ export function AnalyticsPanel({
{chartTab === 'overview' && ( {chartTab === 'overview' && (
<> <>
<div className="admin-analytics-grid"> <div className="admin-analytics-grid">
<div className="admin-analytics-card"> <div className="admin-analytics-card admin-analytics-card--wide">
<h3 className="admin-analytics-card-title">7-Day Trend (Real vs Bot)</h3> <h3 className="admin-analytics-card-title">{trendTitle}</h3>
<Line data={dailyChartData} options={chartOptions} /> <Line data={trendChartData} options={chartOptions} />
</div>
<div className="admin-analytics-card">
<h3 className="admin-analytics-card-title">Traffic Composition</h3>
<Doughnut data={trafficRatioData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
</div> </div>
</div> </div>
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}> <div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
<div className="admin-analytics-card admin-analytics-card--wide"> <div className="admin-analytics-card">
<h3 className="admin-analytics-card-title">30-Day Real Visitor Trend</h3> <h3 className="admin-analytics-card-title">Traffic Composition ({rangeLabel})</h3>
<Line data={thirtyDayChartData} options={chartOptions} /> <Doughnut data={trafficRatioData} options={chartOptions} />
</div>
<div className="admin-analytics-card">
<h3 className="admin-analytics-card-title">Top Pages ({rangeLabel})</h3>
<ul className="admin-bot-reason-list">
{topPathsReal.slice(0, 8).map((item: { path: string; hits: number }) => (
<li key={item.path}><span>{item.path}</span><strong>{item.hits.toLocaleString()}</strong></li>
))}
</ul>
</div> </div>
</div> </div>
</> </>
@@ -353,7 +362,7 @@ export function AnalyticsPanel({
<div className="admin-analytics-grid"> <div className="admin-analytics-grid">
<div className="admin-analytics-card"> <div className="admin-analytics-card">
<h3 className="admin-analytics-card-title">Device Breakdown</h3> <h3 className="admin-analytics-card-title">Device Breakdown</h3>
<Doughnut data={deviceChartData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} /> <Doughnut data={deviceChartData} options={chartOptions} />
</div> </div>
<div className="admin-analytics-card"> <div className="admin-analytics-card">
<h3 className="admin-analytics-card-title">Device Counts</h3> <h3 className="admin-analytics-card-title">Device Counts</h3>
@@ -371,7 +380,7 @@ export function AnalyticsPanel({
{botReasons.length === 0 ? ( {botReasons.length === 0 ? (
<p className="admin-stats-note">No bot traffic detected.</p> <p className="admin-stats-note">No bot traffic detected.</p>
) : ( ) : (
<Pie data={botPieData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} /> <Pie data={botPieData} options={chartOptions} />
)} )}
</div> </div>
<div className="admin-analytics-card"> <div className="admin-analytics-card">
@@ -390,20 +399,65 @@ export function AnalyticsPanel({
)} )}
</div> </div>
</div> </div>
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
<div className="admin-analytics-card admin-analytics-card--wide">
<h3 className="admin-analytics-card-title">Probed URLs (not real pages)</h3>
{(stats.topUnknownPaths ?? []).length === 0 ? (
<p className="admin-stats-note">No probe traffic recorded yet. Requests for URLs that are not site pages (like /.env or /wp-login) are listed here and counted as bots.</p>
) : (
<ul className="admin-bot-reason-list">
{(stats.topUnknownPaths ?? []).map(row => (
<li key={row.path} className="admin-bot-reason-item">
<span style={{ wordBreak: 'break-all' }}>{row.path}</span>
<strong>{row.hits.toLocaleString()}</strong>
</li>
))}
</ul>
)}
</div>
</div>
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
<div className="admin-analytics-card admin-analytics-card--wide">
<h3 className="admin-analytics-card-title">Top Pages — Real vs Bot ({rangeLabel})</h3>
<Bar data={topPathsChartData} options={{...chartOptions, indexAxis: 'y' as const}} />
</div>
</div>
</> </>
)} )}
{/* Geographic Charts */} {/* Geographic Charts */}
{chartTab === 'geographic' && ( {chartTab === 'geographic' && (
<div className="admin-analytics-geo"> <div className="admin-analytics-geo">
<div className="admin-analytics-card admin-analytics-card--wide">
<h3 className="admin-analytics-card-title">Top Pages</h3>
<Bar data={topPathsChartData} options={{...chartOptions, indexAxis: 'y' as const}} />
</div>
<GeoMaps <GeoMaps
topCountries={stats.visitors.topCountries} topCountries={stats.visitors.topCountries}
topStates={stats.visitors.topStates} topStates={stats.visitors.topStates}
/> />
<div className="admin-stats-lists" style={{ marginTop: '1rem' }}>
<div>
<h3>Top Cities</h3>
<ul>
{(stats.visitors.topCities ?? []).slice(0, 10).map((item: { name: string; hits: number }) => (
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
))}
</ul>
</div>
<div>
<h3>Top States</h3>
<ul>
{stats.visitors.topStates.slice(0, 10).map((item: { name: string; hits: number }) => (
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
))}
</ul>
</div>
<div>
<h3>Top Countries</h3>
<ul>
{stats.visitors.topCountries.slice(0, 10).map((item: { name: string; hits: number }) => (
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
))}
</ul>
</div>
</div>
</div> </div>
)} )}
@@ -411,7 +465,7 @@ export function AnalyticsPanel({
{chartTab === 'referrers' && ( {chartTab === 'referrers' && (
<div className="admin-analytics-grid"> <div className="admin-analytics-grid">
<div className="admin-analytics-card admin-analytics-card--wide"> <div className="admin-analytics-card admin-analytics-card--wide">
<h3 className="admin-analytics-card-title">Top Traffic Sources</h3> <h3 className="admin-analytics-card-title">Top Traffic Sources ({rangeLabel})</h3>
{(stats.visitors.topReferrers ?? []).length === 0 ? ( {(stats.visitors.topReferrers ?? []).length === 0 ? (
<p className="admin-stats-note">No referrer data yet. Referrers are captured on SPA navigations after consent.</p> <p className="admin-stats-note">No referrer data yet. Referrers are captured on SPA navigations after consent.</p>
) : ( ) : (
@@ -428,38 +482,21 @@ export function AnalyticsPanel({
</div> </div>
)} )}
{/* Recent Visitors Table */} {/* Recent Visitors */}
<div className="admin-stats-head admin-stats-head--visitors"> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Visitor Details</h2> <h2>Visitor Log</h2>
<p>Real-time tracking of visitor activity, geography, and returning behavior.</p> <p>Activity for {rangeLabel.toLowerCase()} · {rangeRealVisitors} records shown.</p>
</div> </div>
<p className="admin-privacy-note">Privacy: visitor analytics only run after cookie consent. IPs below are masked.</p> <p className="admin-privacy-note">Analytics only run after cookie consent. Visitor IDs are pseudonymous hash prefixes.</p>
<div className="admin-stats-grid admin-stats-grid--visitor"> <div className="admin-stats-grid admin-stats-grid--visitor">
<article><h3>Total Visits</h3><p>{stats.visitors.totalVisits.toLocaleString()}</p></article> <article><h3>Total Visits</h3><p>{stats.visitors.totalVisits.toLocaleString()}</p><small>All time</small></article>
<article><h3>Unique Visitors</h3><p>{stats.visitors.uniqueVisitors.toLocaleString()}</p></article>
<article><h3>Returning Visits</h3><p>{stats.visitors.returningVisits.toLocaleString()}</p></article> <article><h3>Returning Visits</h3><p>{stats.visitors.returningVisits.toLocaleString()}</p></article>
<article><h3>First Visit</h3><p>{formatDate(stats.visitors.firstVisitAt)}</p></article> <article><h3>First Visit</h3><p>{formatDate(stats.visitors.firstVisitAt)}</p></article>
</div> </div>
{/* Top Countries/States/Cities live in the Geographic tab above, alongside
<div className="admin-stats-lists"> the map — not repeated here to avoid showing the same numbers twice
<div> on one page. */}
<h3>Top Countries</h3>
<ul>
{stats.visitors.topCountries.map((item: { name: string; hits: number }) => (
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
))}
</ul>
</div>
<div>
<h3>Top States</h3>
<ul>
{stats.visitors.topStates.map((item: { name: string; hits: number }) => (
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
))}
</ul>
</div>
</div>
<div className="admin-visits-table-wrap"> <div className="admin-visits-table-wrap">
<h3>Recent Visitor Log</h3> <h3>Recent Visitor Log</h3>
@@ -472,6 +509,9 @@ export function AnalyticsPanel({
const isExpanded = expandedVisitor === rowKey const isExpanded = expandedVisitor === rowKey
const history = row.pageHistory ?? [] const history = row.pageHistory ?? []
const location = [row.city !== 'Unknown' && row.city, row.state !== 'Unknown' && row.state, row.country !== 'Unknown' && row.country].filter(Boolean).join(', ') || 'Unknown location' const location = [row.city !== 'Unknown' && row.city, row.state !== 'Unknown' && row.state, row.country !== 'Unknown' && row.country].filter(Boolean).join(', ') || 'Unknown location'
const visitorLabel = row.studyUser
? (row.studyUser.displayName || row.studyUser.username)
: `Visitor ${(row.ipHash ?? row.visitorId).slice(0, 8)}`
return ( return (
<div key={rowKey} className={`admin-visitor-card${isExpanded ? ' admin-visitor-card--expanded' : ''}`}> <div key={rowKey} className={`admin-visitor-card${isExpanded ? ' admin-visitor-card--expanded' : ''}`}>
<button <button
@@ -481,7 +521,9 @@ export function AnalyticsPanel({
> >
<div className="admin-visitor-card__left"> <div className="admin-visitor-card__left">
<span className="admin-visitor-card__name"> <span className="admin-visitor-card__name">
{row.studyUser ? (row.studyUser.displayName || row.studyUser.username) : <code className="admin-visitor-ip">{maskIp(row.ip)}</code>} {row.studyUser
? visitorLabel
: <code className="admin-visitor-ip">{visitorLabel}</code>}
</span> </span>
<span className="admin-visitor-card__path">{row.path}</span> <span className="admin-visitor-card__path">{row.path}</span>
</div> </div>
@@ -502,7 +544,7 @@ export function AnalyticsPanel({
{isExpanded && ( {isExpanded && (
<div className="admin-visitor-card__detail"> <div className="admin-visitor-card__detail">
<div className="admin-visitor-card__detail-meta"> <div className="admin-visitor-card__detail-meta">
<span><strong>IP</strong> {maskIp(row.ip)}</span> <span><strong>ID</strong> {(row.ipHash ?? row.visitorId).slice(0, 12)}…</span>
{row.studyUser && <span><strong>Username</strong> {row.studyUser.username}</span>} {row.studyUser && <span><strong>Username</strong> {row.studyUser.username}</span>}
{row.referrer && <span><strong>Referrer</strong> {row.referrer}</span>} {row.referrer && <span><strong>Referrer</strong> {row.referrer}</span>}
<span><strong>Location</strong> {[row.city, row.county, row.state, row.country].filter(v => v && v !== 'Unknown').join(', ') || '—'}</span> <span><strong>Location</strong> {[row.city, row.county, row.state, row.country].filter(v => v && v !== 'Unknown').join(', ') || '—'}</span>
@@ -540,7 +582,12 @@ export function AnalyticsPanel({
<> <>
<div className="admin-stats-head admin-stats-head--visitors"> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Episode Plays</h2> <h2>Episode Plays</h2>
<p>Tracked each time a visitor hits play on the audio player. One count per player mount.</p> <p>Counted each time a visitor presses play. One count per player mount.</p>
</div>
<div className="admin-stats-grid" style={{ marginBottom: '1.5rem' }}>
<article><h3>Total Plays</h3><p>{stats.episodePlays.reduce((sum: number, ep: { total: number }) => sum + ep.total, 0).toLocaleString()}</p></article>
<article><h3>Episodes Played</h3><p>{stats.episodePlays.length.toLocaleString()}</p></article>
<article><h3>Most Played</h3><p style={{ fontSize: '0.8rem' }}>{stats.episodePlays[0]?.title ?? '—'}</p><small>{stats.episodePlays[0]?.total.toLocaleString()} plays</small></article>
</div> </div>
<div className="admin-stats-chart-wrap" style={{ marginBottom: '1.5rem' }}> <div className="admin-stats-chart-wrap" style={{ marginBottom: '1.5rem' }}>
<Bar <Bar
@@ -571,11 +618,6 @@ export function AnalyticsPanel({
style={{ height: `${Math.max(180, stats.episodePlays.slice(0, 12).length * 36)}px` }} style={{ height: `${Math.max(180, stats.episodePlays.slice(0, 12).length * 36)}px` }}
/> />
</div> </div>
<div className="admin-stats-grid" style={{ marginBottom: '1.5rem' }}>
<article><h3>Total Episode Plays</h3><p>{stats.episodePlays.reduce((sum: number, ep: { total: number }) => sum + ep.total, 0).toLocaleString()}</p></article>
<article><h3>Episodes Played</h3><p>{stats.episodePlays.length.toLocaleString()}</p></article>
<article><h3>Most Played</h3><p style={{ fontSize: '0.8rem' }}>{stats.episodePlays[0]?.title ?? '—'}</p></article>
</div>
</> </>
)} )}
@@ -584,10 +626,9 @@ export function AnalyticsPanel({
<> <>
<div className="admin-stats-head admin-stats-head--visitors"> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Engagement</h2> <h2>Engagement</h2>
<p>Scroll depth, time on page, audio, and interaction data from consenting visitors.</p> <p>Scroll depth, time on page, audio, and interaction data. Consent-gated.</p>
</div> </div>
{/* Time on page */}
{stats.engagement.timeOnPage?.length > 0 && ( {stats.engagement.timeOnPage?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Avg. Time on Page</h3> <h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Avg. Time on Page</h3>
@@ -598,7 +639,7 @@ export function AnalyticsPanel({
{stats.engagement.timeOnPage.slice(0, 15).map((row: { path: string; avgSeconds: number; count: number }) => ( {stats.engagement.timeOnPage.slice(0, 15).map((row: { path: string; avgSeconds: number; count: number }) => (
<tr key={row.path}> <tr key={row.path}>
<td>{row.path}</td> <td>{row.path}</td>
<td>{row.avgSeconds >= 60 ? `${Math.floor(row.avgSeconds / 60)}m ${row.avgSeconds % 60}s` : `${row.avgSeconds}s`}</td> <td>{fmtSec(row.avgSeconds)}</td>
<td>{row.count.toLocaleString()}</td> <td>{row.count.toLocaleString()}</td>
</tr> </tr>
))} ))}
@@ -608,7 +649,6 @@ export function AnalyticsPanel({
</div> </div>
)} )}
{/* Scroll depth */}
{stats.engagement.scrollDepth?.length > 0 && ( {stats.engagement.scrollDepth?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Scroll Depth</h3> <h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Scroll Depth</h3>
@@ -631,7 +671,6 @@ export function AnalyticsPanel({
</div> </div>
)} )}
{/* Audio events */}
{stats.engagement.audioEvents?.length > 0 && ( {stats.engagement.audioEvents?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Audio Engagement</h3> <h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Audio Engagement</h3>
@@ -639,26 +678,20 @@ export function AnalyticsPanel({
<table className="admin-stats-table"> <table className="admin-stats-table">
<thead><tr><th>Episode</th><th>Completions</th><th>Pauses</th><th>Total Listen</th></tr></thead> <thead><tr><th>Episode</th><th>Completions</th><th>Pauses</th><th>Total Listen</th></tr></thead>
<tbody> <tbody>
{stats.engagement.audioEvents.map((row: { title: string; pauses: number; completions: number; totalListenSeconds: number }) => { {stats.engagement.audioEvents.map((row: { title: string; pauses: number; completions: number; totalListenSeconds: number }) => (
const hrs = Math.floor(row.totalListenSeconds / 3600) <tr key={row.title}>
const mins = Math.floor((row.totalListenSeconds % 3600) / 60) <td style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{row.title}</td>
const listenStr = hrs > 0 ? `${hrs}h ${mins}m` : `${mins}m` <td>{row.completions.toLocaleString()}</td>
return ( <td>{row.pauses.toLocaleString()}</td>
<tr key={row.title}> <td>{fmtSec(row.totalListenSeconds)}</td>
<td style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{row.title}</td> </tr>
<td>{row.completions.toLocaleString()}</td> ))}
<td>{row.pauses.toLocaleString()}</td>
<td>{listenStr}</td>
</tr>
)
})}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
)} )}
{/* Search queries */}
{stats.engagement.topSearchQueries?.length > 0 && ( {stats.engagement.topSearchQueries?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Top Search Queries</h3> <h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Top Search Queries</h3>
@@ -675,7 +708,6 @@ export function AnalyticsPanel({
</div> </div>
)} )}
{/* All link clicks */}
{stats.engagement.topLinkClicks?.length > 0 && ( {stats.engagement.topLinkClicks?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Link Clicks</h3> <h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Link Clicks</h3>
@@ -702,7 +734,6 @@ export function AnalyticsPanel({
</div> </div>
)} )}
{/* Outbound clicks */}
{stats.engagement.topOutboundClicks?.length > 0 && ( {stats.engagement.topOutboundClicks?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Outbound Link Clicks</h3> <h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Outbound Link Clicks</h3>
@@ -724,7 +755,6 @@ export function AnalyticsPanel({
</div> </div>
)} )}
{/* UTM sources */}
{stats.engagement.topUTMSources?.length > 0 && ( {stats.engagement.topUTMSources?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>UTM Sources</h3> <h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>UTM Sources</h3>
@@ -741,7 +771,6 @@ export function AnalyticsPanel({
</div> </div>
)} )}
{/* 404s */}
{stats.engagement.top404s?.length > 0 && ( {stats.engagement.top404s?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>404 Not Found</h3> <h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>404 Not Found</h3>
@@ -763,11 +792,11 @@ export function AnalyticsPanel({
{/* Contact Summary */} {/* Contact Summary */}
<div className="admin-stats-head admin-stats-head--visitors"> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Contact Summary</h2> <h2>Contact Summary</h2>
<p>Submission totals from the contact form.</p> <p>All-time submission totals from the contact form.</p>
</div> </div>
<div className="admin-stats-grid"> <div className="admin-stats-grid">
<article><h3>Total Contact Messages</h3><p>{stats.contactTotals.totalSubmissions.toLocaleString()}</p></article> <article><h3>Total Messages</h3><p>{stats.contactTotals.totalSubmissions.toLocaleString()}</p></article>
<article><h3>Total Bible Questions</h3><p>{stats.contactTotals.totalQuestions.toLocaleString()}</p></article> <article><h3>Bible Questions</h3><p>{stats.contactTotals.totalQuestions.toLocaleString()}</p></article>
</div> </div>
{/* Study Enrollment Funnel */} {/* Study Enrollment Funnel */}
@@ -775,7 +804,7 @@ export function AnalyticsPanel({
<> <>
<div className="admin-stats-head admin-stats-head--visitors"> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Study Enrollment Funnel</h2> <h2>Study Enrollment Funnel</h2>
<p>How many users progress from signup → first visit → first section completed.</p> <p>Signup → first study visit → first section completed.</p>
</div> </div>
<div className="admin-funnel"> <div className="admin-funnel">
{(() => { {(() => {
@@ -805,10 +834,9 @@ export function AnalyticsPanel({
</>)} </>)}
{panelTab === 'system' && (<> {panelTab === 'system' && (<>
{/* Deployment & Cache Status */}
<div className="admin-stats-head admin-stats-head--visitors"> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Deployment &amp; Cache Status</h2> <h2>Deployment &amp; Cache Status</h2>
<p>Current deployment metadata and cache purge health for the live app.</p> <p>Current deployment metadata and cache purge health.</p>
</div> </div>
<div className="admin-stats-grid"> <div className="admin-stats-grid">
<article><h3>Build Commit</h3><p>{opsStatus?.buildCommit ?? 'Not available'}</p></article> <article><h3>Build Commit</h3><p>{opsStatus?.buildCommit ?? 'Not available'}</p></article>
@@ -817,7 +845,6 @@ export function AnalyticsPanel({
<article><h3>Cache Purge</h3><p>{opsStatus?.cachePurge.ok ? 'Healthy' : 'Needs setup'}</p><p>{formatDate(opsStatus?.cachePurge.at ?? null)}</p></article> <article><h3>Cache Purge</h3><p>{opsStatus?.cachePurge.ok ? 'Healthy' : 'Needs setup'}</p><p>{formatDate(opsStatus?.cachePurge.at ?? null)}</p></article>
</div> </div>
{/* Data Management */}
<div className="admin-stats-head admin-stats-head--visitors"> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Data Management</h2> <h2>Data Management</h2>
<p>Export, backup, or retain only recent analytics data.</p> <p>Export, backup, or retain only recent analytics data.</p>
@@ -857,7 +884,6 @@ export function AnalyticsPanel({
</div> </div>
)} )}
{/* Full Data Backup (entire /data folder — server migration) */}
<div className="admin-stats-head admin-stats-head--visitors"> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Full Data Backup</h2> <h2>Full Data Backup</h2>
<p>Download every persisted file — site content, uploads, study accounts, notes, progress, questions, analytics — plus a snapshot of server settings (env-snapshot.env) as one archive, or restore that archive on this or a new server. The archive contains secrets like the admin password and API keys, so keep it private. Restored settings are not applied automatically — copy them into the new server&apos;s environment.</p> <p>Download every persisted file — site content, uploads, study accounts, notes, progress, questions, analytics — plus a snapshot of server settings (env-snapshot.env) as one archive, or restore that archive on this or a new server. The archive contains secrets like the admin password and API keys, so keep it private. Restored settings are not applied automatically — copy them into the new server&apos;s environment.</p>
@@ -869,8 +895,6 @@ export function AnalyticsPanel({
</div> </div>
<div className="admin-restore-row"> <div className="admin-restore-row">
<label htmlFor="full-backup-file">Restore from File</label> <label htmlFor="full-backup-file">Restore from File</label>
{/* No accept filter: iPadOS greys out .tar.gz with one — the server
validates the archive contents before restoring anyway. */}
<input <input
id="full-backup-file" id="full-backup-file"
type="file" type="file"
+73 -46
View File
@@ -2,7 +2,8 @@ import { useState, useEffect } from 'react'
import type { ChangeEvent } from 'react' import type { ChangeEvent } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/ const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]{1,64}@[a-zA-Z0-9.-]{1,253}\.[a-zA-Z]{2,}$/
const MAX_MESSAGE = 3000
interface ContactFields { interface ContactFields {
firstName: string firstName: string
@@ -12,6 +13,15 @@ interface ContactFields {
messageType: string messageType: string
} }
const TYPES: { value: string; label: string; hint: string; placeholder: string }[] = [
{ value: 'question', label: 'Bible question', hint: 'Ask anything about Scripture. Answers are posted on the Q&A page.', placeholder: 'What would you like to know?' },
{ value: 'gospel', label: 'Gospel courtroom', hint: 'A question about the gospel courtroom: the Judge, the accuser, the charges, the Substitute, the pardon or your plea. It goes to Nate privately and is not posted publicly.', placeholder: 'What would you like to ask about the courtroom, the verdict or the pardon?' },
{ value: 'community', label: 'Community question', hint: 'A question about a study discussion or the community.', placeholder: 'What’s on your mind?' },
{ value: 'testimony', label: 'Testimony', hint: 'Share what God is doing in your life. Every one is read.', placeholder: 'Tell your story…' },
{ value: 'topic', label: 'Topic request', hint: 'Suggest a passage or topic for a future episode.', placeholder: 'Which passage or topic?' },
{ value: 'general', label: 'General message', hint: 'Anything else you’d like to say.', placeholder: 'Write your message…' },
]
export default function ContactForm() { export default function ContactForm() {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
@@ -23,7 +33,7 @@ export default function ContactForm() {
lastName: '', lastName: '',
email: '', email: '',
message: '', message: '',
messageType: source === 'community' ? 'community' : 'question', messageType: source === 'community' ? 'community' : source === 'salvation' ? 'gospel' : 'question',
}) })
useEffect(() => { useEffect(() => {
@@ -42,6 +52,8 @@ export default function ContactForm() {
const [errorMsg, setErrorMsg] = useState('') const [errorMsg, setErrorMsg] = useState('')
const [fieldErrors, setFieldErrors] = useState<{ email?: string }>({}) const [fieldErrors, setFieldErrors] = useState<{ email?: string }>({})
const activeType = TYPES.find(t => t.value === fields.messageType) ?? TYPES[0]
function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) { function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
const { name, value } = e.target const { name, value } = e.target
setFields(f => ({ ...f, [name]: value })) setFields(f => ({ ...f, [name]: value }))
@@ -59,10 +71,6 @@ export default function ContactForm() {
} }
} }
function handleSelectChange(e: ChangeEvent<HTMLSelectElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
}
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
setStatus('submitting') setStatus('submitting')
@@ -90,7 +98,7 @@ export default function ContactForm() {
} }
return ( return (
<form className="contact-form" onSubmit={handleSubmit} noValidate> <form className="cx-form" onSubmit={handleSubmit} noValidate>
<input <input
type="text" type="text"
className="contact-honeypot" className="contact-honeypot"
@@ -100,14 +108,37 @@ export default function ContactForm() {
value={honey} value={honey}
onChange={e => setHoney(e.target.value)} onChange={e => setHoney(e.target.value)}
/> />
<label>
First Name <fieldset className="cx-types">
<input type="text" name="firstName" required autoComplete="given-name" value={fields.firstName} onChange={handleChange} /> <legend>What is this about?</legend>
</label> <div className="cx-type-row" role="radiogroup">
<label> {TYPES.map(t => (
Last Name <label key={t.value} className="cx-type">
<input type="text" name="lastName" required autoComplete="family-name" value={fields.lastName} onChange={handleChange} /> <input
</label> type="radio"
name="messageType"
value={t.value}
checked={fields.messageType === t.value}
onChange={() => setFields(f => ({ ...f, messageType: t.value }))}
/>
<span>{t.label}</span>
</label>
))}
</div>
<p className="cx-hint" aria-live="polite">{activeType.hint}</p>
</fieldset>
<div className="cx-row">
<label>
First name
<input type="text" name="firstName" required autoComplete="given-name" value={fields.firstName} onChange={handleChange} />
</label>
<label>
Last name
<input type="text" name="lastName" required autoComplete="family-name" value={fields.lastName} onChange={handleChange} />
</label>
</div>
<label> <label>
Email Email
<input <input
@@ -122,41 +153,37 @@ export default function ContactForm() {
/> />
{fieldErrors.email && <span id="contact-email-error" className="study-signup-field-error">{fieldErrors.email}</span>} {fieldErrors.email && <span id="contact-email-error" className="study-signup-field-error">{fieldErrors.email}</span>}
</label> </label>
<label>
Message Type
<select name="messageType" value={fields.messageType} onChange={handleSelectChange}>
<option value="question">Bible Question</option>
<option value="community">Community Question</option>
<option value="testimony">Testimony</option>
<option value="topic">Topic Request</option>
<option value="general">General Message</option>
</select>
</label>
<label> <label>
Message Message
<textarea name="message" rows={6} required value={fields.message} onChange={handleChange} /> <textarea
</label> name="message"
{fields.messageType === 'question' && ( rows={6}
<label className="contact-consent"> required
<input maxLength={MAX_MESSAGE}
type="checkbox" placeholder={activeType.placeholder}
checked={notifyOnAnswer} value={fields.message}
onChange={e => setNotifyOnAnswer(e.target.checked)} onChange={handleChange}
/>
<span>Email me when this question is answered.</span>
</label>
)}
<label className="contact-consent">
<input
type="checkbox"
checked={subscribe}
onChange={e => setSubscribe(e.target.checked)}
/> />
<span>Send me updates from Verse by Verse with Nate. I can unsubscribe anytime.</span> <span className="cx-count">{fields.message.length} / {MAX_MESSAGE}</span>
</label> </label>
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
<button type="submit" className="btn-primary" disabled={status === 'submitting'}> <div className="cx-checks">
{status === 'submitting' ? 'Sending…' : 'Send Message'} {fields.messageType === 'question' && (
<label className="cx-check">
<input type="checkbox" checked={notifyOnAnswer} onChange={e => setNotifyOnAnswer(e.target.checked)} />
<span>Email me when this question is answered.</span>
</label>
)}
<label className="cx-check">
<input type="checkbox" checked={subscribe} onChange={e => setSubscribe(e.target.checked)} />
<span>Send me updates from Verse by Verse with Nate. I can unsubscribe anytime.</span>
</label>
</div>
{status === 'error' && <p className="contact-error" role="alert">{errorMsg}</p>}
<button type="submit" className="btn-primary cx-submit" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Sending…' : 'Send message'}
</button> </button>
</form> </form>
) )
+2 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { sendEvent } from '../analytics' import { sendEvent, checkAnalyticsConsentResponse } from '../analytics'
interface EpisodeAudioPlayerProps { interface EpisodeAudioPlayerProps {
src: string src: string
@@ -101,7 +101,7 @@ export function EpisodeAudioPlayer({ src, title, size = 'full', spotifyUrl }: Ep
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }), body: JSON.stringify({ title }),
}).catch(() => {}) }).then(checkAnalyticsConsentResponse).catch(() => {})
} }
}).catch(() => {}) }).catch(() => {})
} }
+5
View File
@@ -8,6 +8,7 @@ const TYPE_LABEL: Record<SearchResultType, string> = {
question: 'Q&A', question: 'Q&A',
resource: 'Download', resource: 'Download',
series: 'Series', series: 'Series',
newsletter: 'Newsletter',
} }
const TYPE_ICON: Record<SearchResultType, string> = { const TYPE_ICON: Record<SearchResultType, string> = {
@@ -15,6 +16,7 @@ const TYPE_ICON: Record<SearchResultType, string> = {
question: '❓', question: '❓',
resource: '📄', resource: '📄',
series: '📚', series: '📚',
newsletter: '✉️',
} }
interface Props { interface Props {
@@ -57,6 +59,9 @@ export function GlobalSearch({ query, setQuery, results }: Props) {
setQuery('') setQuery('')
if (result.href.startsWith('http')) { if (result.href.startsWith('http')) {
window.open(result.href, '_blank', 'noreferrer') window.open(result.href, '_blank', 'noreferrer')
} else if (result.type === 'newsletter') {
// Newsletter issues are server-rendered outside the SPA (for social-share OG tags), so use a full navigation.
window.location.href = result.href
} else { } else {
navigate(result.href) navigate(result.href)
} }
+99
View File
@@ -0,0 +1,99 @@
import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
interface AccountStatus {
authenticated: boolean
username: string
displayName?: string
avatarUrl?: string
adminShortcut?: boolean
}
// Shared across header instances so moving between pages doesn't flash the signed-out icon
let cachedStatus: AccountStatus | null = null
function PersonIcon() {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-3.6 3.6-6 8-6s8 2.4 8 6" />
</svg>
)
}
export function HeaderAccount() {
const [status, setStatus] = useState<AccountStatus | null>(cachedStatus)
const [open, setOpen] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
useEffect(() => {
let cancelled = false
fetch('/api/study-auth/status')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('status failed'))))
.then((data: AccountStatus) => {
cachedStatus = data
if (!cancelled) setStatus(data)
})
.catch(() => {})
return () => { cancelled = true }
}, [])
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent | TouchEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', onDown)
document.addEventListener('touchstart', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('touchstart', onDown)
document.removeEventListener('keydown', onKey)
}
}, [open])
async function signOut() {
try { await fetch('/api/study-auth/logout', { method: 'POST' }) } catch { /* reload below shows the real state */ }
cachedStatus = null
window.location.assign('/')
}
// Signed out (or still checking): one tap goes to the sign-in page
if (!status?.authenticated) {
return (
<Link to="/study/account" className="header-account" aria-label="Sign in or create an account" title="Sign in">
<PersonIcon />
</Link>
)
}
const name = status.displayName?.trim() || status.username
const initial = (name || '?').charAt(0).toUpperCase()
return (
<div className="header-account-wrap" ref={wrapRef}>
<button
type="button"
className="header-account header-account--in"
aria-haspopup="menu"
aria-expanded={open}
aria-label={`Account menu for ${name}`}
onClick={() => setOpen(o => !o)}
>
{status.avatarUrl ? <img src={status.avatarUrl} alt="" className="header-account-avatar" /> : <span aria-hidden="true">{initial}</span>}
</button>
{open && (
<div className="header-account-menu" role="menu">
<p className="header-account-name">Signed in as <strong>{name}</strong></p>
<Link to="/study/account" role="menuitem" className="header-account-item" onClick={() => setOpen(false)}>My Account</Link>
{status.adminShortcut && (
<Link to="/admin" role="menuitem" className="header-account-item" onClick={() => setOpen(false)}>Admin</Link>
)}
<button type="button" role="menuitem" className="header-account-item" onClick={signOut}>Sign out</button>
</div>
)}
</div>
)
}
+40 -1
View File
@@ -5,14 +5,17 @@ import { useEffect } from 'react'
interface NoteCardProps { interface NoteCardProps {
anchorLabel: string anchorLabel: string
html: string html: string
updatedAt?: string | null
onDelete?: () => void
autoSaveMsg?: string autoSaveMsg?: string
onChange: (html: string) => void onChange: (html: string) => void
onClose: () => void onClose: () => void
} }
export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }: NoteCardProps) { export function NoteCard({ anchorLabel, html, updatedAt, onDelete, autoSaveMsg, onChange, onClose }: NoteCardProps) {
const editor = useEditor({ const editor = useEditor({
extensions: [StarterKit], extensions: [StarterKit],
shouldRerenderOnTransaction: true,
content: html || '<p></p>', content: html || '<p></p>',
onUpdate({ editor }) { onUpdate({ editor }) {
const next = editor.getHTML() const next = editor.getHTML()
@@ -32,6 +35,8 @@ export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }:
if (!editor) return null if (!editor) return null
const wordCount = editor.getText().split(/\s+/).filter(Boolean).length
return ( return (
<div className="note-card"> <div className="note-card">
<div className="note-card-header"> <div className="note-card-header">
@@ -71,6 +76,27 @@ export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }:
aria-label="Numbered list" aria-label="Numbered list"
title="Numbered list" title="Numbered list"
>1.</button> >1.</button>
<button
type="button"
className={`note-tool${editor.isActive('heading', { level: 3 }) ? ' note-tool--active' : ''}`}
onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleHeading({ level: 3 }).run() }}
aria-label="Heading"
title="Heading"
>H</button>
<button
type="button"
className={`note-tool${editor.isActive('blockquote') ? ' note-tool--active' : ''}`}
onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleBlockquote().run() }}
aria-label="Quote"
title="Quote"
>❝</button>
<button
type="button"
className={`note-tool${editor.isActive('strike') ? ' note-tool--active' : ''}`}
onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleStrike().run() }}
aria-label="Strikethrough"
title="Strikethrough"
><s>S</s></button>
<div className="note-tool-divider" /> <div className="note-tool-divider" />
<button <button
type="button" type="button"
@@ -91,6 +117,19 @@ export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }:
</div> </div>
<EditorContent editor={editor} className="note-card-editor" /> <EditorContent editor={editor} className="note-card-editor" />
<div className="note-card-footer">
{onDelete && (
<button
type="button"
className="note-card-delete"
onClick={() => { if (window.confirm('Delete this note?')) onDelete() }}
>Delete note</button>
)}
<span>
{updatedAt ? `Edited ${new Date(updatedAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} · ` : ''}
{wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'}
</span>
</div>
</div> </div>
) )
} }
+830
View File
@@ -0,0 +1,830 @@
import { useState, useEffect, useRef } from 'react'
const DRAFT_KEY = 'nuggets_ep_draft'
interface HistoryEntry {
episodeName: string
passage: string
filename: string
uploadedAt: string
}
type Screen = 'checking' | 'login' | 'otp' | 'portal' | 'settings'
function formatBytes(b: number) {
if (b >= 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(1)} MB`
if (b >= 1024) return `${(b / 1024).toFixed(0)} KB`
return `${b} B`
}
function saveDraft(ep: string, passage: string, desc: string) {
try { localStorage.setItem(DRAFT_KEY, JSON.stringify({ ep, passage, desc })) } catch { /* storage unavailable */ }
}
function loadDraft() {
try {
const raw = localStorage.getItem(DRAFT_KEY)
if (!raw) return null
return JSON.parse(raw) as { ep: string; passage: string; desc: string }
} catch { return null }
}
function clearDraft() {
try { localStorage.removeItem(DRAFT_KEY) } catch { /* storage unavailable */ }
}
export function NuggetsHostPortal() {
const [screen, setScreen] = useState<Screen>('checking')
const [hostName, setHostName] = useState('')
const [seafileConfigured, setSeafileConfigured] = useState(true)
const [twoFaEnabled, setTwoFaEnabled] = useState(false)
const [announcement, setAnnouncement] = useState('')
const [guidelines, setGuidelines] = useState<string[]>([
'Export as MP3 or WAV at 44.1 kHz, 128 kbps minimum.',
'File name: YYYY-MM-DD-EpisodeName.mp3 (e.g. 2026-09-09-Romans-5.mp3).',
'Recommended length: 8–15 minutes. Keep a 0.5s lead-in and 1s fade-out.',
'Normalize to -1 dBFS before exporting.',
'Match the episode name here to the file name exactly so search stays clean.',
])
// Login state
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loginError, setLoginError] = useState('')
const [loggingIn, setLoggingIn] = useState(false)
// OTP state
const [otpToken, setOtpToken] = useState('')
const [otpCode, setOtpCode] = useState('')
const [otpError, setOtpError] = useState('')
const [verifyingOtp, setVerifyingOtp] = useState(false)
// Upload form state
const [episodeName, setEpisodeName] = useState('')
const [passage, setPassage] = useState('')
const [description, setDescription] = useState('')
const [file, setFile] = useState<File | null>(null)
const [fileWarning, setFileWarning] = useState('')
const [dragging, setDragging] = useState(false)
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [uploadError, setUploadError] = useState('')
const [lastSuccess, setLastSuccess] = useState<{ episodeName: string; filename: string; seafileUrl?: string } | null>(null)
const [guidelinesOpen, setGuidelinesOpen] = useState(false)
// History
const [history, setHistory] = useState<HistoryEntry[]>([])
const [historyOpen, setHistoryOpen] = useState(false)
// Settings state
const [currentPw, setCurrentPw] = useState('')
const [newPw, setNewPw] = useState('')
const [confirmPw, setConfirmPw] = useState('')
const [pwError, setPwError] = useState('')
const [pwSuccess, setPwSuccess] = useState('')
const [savingPw, setSavingPw] = useState(false)
const [toggling2fa, setToggling2fa] = useState(false)
const [twoFaMsg, setTwoFaMsg] = useState('')
const fileInputRef = useRef<HTMLInputElement>(null)
// Load session on mount
useEffect(() => {
fetch('/api/nuggets-upload/me')
.then(r => r.json())
.then(d => {
if (d.authenticated) {
setHostName(d.hostName ?? '')
setSeafileConfigured(d.configured !== false)
setTwoFaEnabled(Boolean(d.twoFaEnabled))
setScreen('portal')
const draft = loadDraft()
if (draft) { setEpisodeName(draft.ep); setPassage(draft.passage); setDescription(draft.desc) }
} else {
setScreen('login')
}
})
.catch(() => setScreen('login'))
}, [])
// Load announcement and guidelines
useEffect(() => {
fetch('/api/nuggets-upload/announcement')
.then(r => r.json())
.then(d => { if (d.text) setAnnouncement(d.text) })
.catch(() => {})
fetch('/api/nuggets-upload/guidelines')
.then(r => r.json())
.then(d => { if (Array.isArray(d.lines) && d.lines.length) setGuidelines(d.lines) })
.catch(() => {})
}, [])
// Load history when history panel is opened
useEffect(() => {
if (historyOpen && screen === 'portal') {
fetch('/api/nuggets-upload/history')
.then(r => r.json())
.then(d => setHistory(d.entries ?? []))
.catch(() => {})
}
}, [historyOpen, screen])
// Autosave draft
useEffect(() => {
if (screen === 'portal') saveDraft(episodeName, passage, description)
}, [episodeName, passage, description, screen])
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setLoginError('')
setLoggingIn(true)
try {
const r = await fetch('/api/nuggets-upload/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const d = await r.json()
if (!r.ok) { setLoginError(d.message ?? 'Login failed.'); return }
if (d.requiresOtp) {
setOtpToken(d.otpToken)
setScreen('otp')
return
}
setHostName(d.hostName)
setTwoFaEnabled(false)
setScreen('portal')
const draft = loadDraft()
if (draft) { setEpisodeName(draft.ep); setPassage(draft.passage); setDescription(draft.desc) }
} finally {
setLoggingIn(false)
}
}
async function handleVerifyOtp(e: React.FormEvent) {
e.preventDefault()
setOtpError('')
setVerifyingOtp(true)
try {
const r = await fetch('/api/nuggets-upload/verify-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ otpToken, code: otpCode }),
})
const d = await r.json()
if (!r.ok) { setOtpError(d.message ?? 'Verification failed.'); return }
setHostName(d.hostName)
setTwoFaEnabled(true)
setScreen('portal')
const draft = loadDraft()
if (draft) { setEpisodeName(draft.ep); setPassage(draft.passage); setDescription(draft.desc) }
} finally {
setVerifyingOtp(false)
}
}
async function handleLogout() {
await fetch('/api/nuggets-upload/logout', { method: 'POST' })
clearDraft()
setHostName('')
setEmail('')
setPassword('')
setEpisodeName('')
setPassage('')
setDescription('')
setFile(null)
setLastSuccess(null)
setHistory([])
setScreen('login')
}
function pickFile(picked: File | null) {
if (!picked) return
setFileWarning('')
if (picked.size > 500 * 1024 * 1024) {
setFileWarning('File exceeds 500 MB limit. Please compress or trim before uploading.')
return
}
if (picked.size < 500 * 1024) {
setFileWarning(`File is only ${formatBytes(picked.size)} — unusually small. Check the recording before submitting.`)
}
setFile(picked)
}
function handleDrop(e: React.DragEvent) {
e.preventDefault()
setDragging(false)
pickFile(e.dataTransfer.files[0] ?? null)
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
pickFile(e.target.files?.[0] ?? null)
e.target.value = ''
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!file) { setUploadError('Please select an audio file.'); return }
if (uploading) return
setUploadError('')
setLastSuccess(null)
setUploading(true)
setProgress(0)
const formData = new FormData()
formData.append('file', file)
formData.append('episodeName', episodeName)
formData.append('passage', passage)
formData.append('description', description)
await new Promise<void>(resolve => {
const xhr = new XMLHttpRequest()
xhr.open('POST', '/api/nuggets-upload/upload')
xhr.upload.addEventListener('progress', ev => {
if (ev.lengthComputable) setProgress(Math.round((ev.loaded / ev.total) * 100))
})
xhr.addEventListener('load', () => {
setUploading(false)
if (xhr.status === 200) {
try {
const d = JSON.parse(xhr.responseText)
setLastSuccess({ episodeName: d.episodeName, filename: d.filename, seafileUrl: d.seafileUrl })
clearDraft()
setEpisodeName('')
setPassage('')
setDescription('')
setFile(null)
setFileWarning('')
setProgress(0)
} catch {
setUploadError('Upload succeeded but the response was unexpected.')
}
} else {
try { setUploadError(JSON.parse(xhr.responseText).message ?? 'Upload failed.') }
catch { setUploadError('Upload failed.') }
}
resolve()
})
xhr.addEventListener('error', () => {
setUploading(false)
setUploadError('Network error during upload.')
resolve()
})
xhr.send(formData)
})
}
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault()
setPwError('')
setPwSuccess('')
if (newPw !== confirmPw) { setPwError('New passwords do not match.'); return }
if (newPw.length < 8) { setPwError('Password must be at least 8 characters.'); return }
setSavingPw(true)
try {
const r = await fetch('/api/nuggets-upload/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: currentPw, newPassword: newPw }),
})
const d = await r.json()
if (!r.ok) { setPwError(d.message ?? 'Failed to change password.'); return }
setPwSuccess('Password changed successfully.')
setCurrentPw('')
setNewPw('')
setConfirmPw('')
} finally {
setSavingPw(false)
}
}
async function handle2faToggle() {
setToggling2fa(true)
setTwoFaMsg('')
try {
const url = twoFaEnabled ? '/api/nuggets-upload/2fa/disable' : '/api/nuggets-upload/2fa/enable'
const r = await fetch(url, { method: 'POST' })
const d = await r.json()
if (!r.ok) { setTwoFaMsg(d.message ?? 'Failed.'); return }
setTwoFaEnabled(!twoFaEnabled)
setTwoFaMsg(twoFaEnabled ? '2FA disabled.' : '2FA enabled. A code will be sent to your email each time you sign in.')
} finally {
setToggling2fa(false)
}
}
// ── Screens ────────────────────────────────────────────────────────────────
if (screen === 'checking') {
return (
<div style={s.page}>
<div style={s.card}>
<p style={{ color: '#888', textAlign: 'center', margin: 0 }}>Loading…</p>
</div>
</div>
)
}
if (screen === 'login') {
return (
<div style={s.page}>
<div style={s.card}>
<div style={s.logoRow}>
<span style={s.logoEmoji}>🎙️</span>
<span style={s.logoTitle}>Nuggets</span>
</div>
<h1 style={s.heading}>Host Upload Portal</h1>
<p style={s.subText}>Sign in with your host credentials to submit an episode.</p>
<form onSubmit={handleLogin} style={s.form}>
<div style={s.field}>
<label style={s.label} htmlFor="hp-u">Email</label>
<input id="hp-u" style={s.input} type="email" autoComplete="email"
value={email} onChange={e => setEmail(e.target.value)} disabled={loggingIn} required />
</div>
<div style={s.field}>
<label style={s.label} htmlFor="hp-p">Password</label>
<input id="hp-p" style={s.input} type="password" autoComplete="current-password"
value={password} onChange={e => setPassword(e.target.value)} disabled={loggingIn} required />
</div>
{loginError && <p style={s.errorMsg}>{loginError}</p>}
<button style={s.btnPrimary} type="submit" disabled={loggingIn}>
{loggingIn ? 'Signing in…' : 'Sign In'}
</button>
</form>
</div>
</div>
)
}
if (screen === 'otp') {
return (
<div style={s.page}>
<div style={s.card}>
<div style={s.logoRow}>
<span style={s.logoEmoji}>🎙️</span>
<span style={s.logoTitle}>Nuggets</span>
</div>
<h1 style={s.heading}>Check Your Email</h1>
<p style={s.subText}>A 6-digit code was sent to your email address. Enter it below to continue.</p>
<form onSubmit={handleVerifyOtp} style={s.form}>
<div style={s.field}>
<label style={s.label} htmlFor="otp-code">Sign-In Code</label>
<input
id="otp-code"
style={{ ...s.input, textAlign: 'center', letterSpacing: '0.3em', fontSize: '1.3rem', fontWeight: 700 }}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
placeholder="000000"
value={otpCode}
onChange={e => setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
disabled={verifyingOtp}
required
/>
</div>
{otpError && <p style={s.errorMsg}>{otpError}</p>}
<button style={s.btnPrimary} type="submit" disabled={verifyingOtp || otpCode.length !== 6}>
{verifyingOtp ? 'Verifying…' : 'Verify Code'}
</button>
<button style={s.btnGhost} type="button" onClick={() => { setScreen('login'); setOtpCode(''); setOtpError('') }}>
← Back to sign in
</button>
</form>
</div>
</div>
)
}
if (screen === 'settings') {
return (
<div style={s.page}>
<div style={{ ...s.card, maxWidth: 460 }}>
{/* Settings header */}
<div style={s.header}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button style={s.btnIcon} onClick={() => { setPwError(''); setPwSuccess(''); setTwoFaMsg(''); setScreen('portal') }} title="Back">
←
</button>
<span style={{ fontWeight: 700, fontSize: '1rem', color: 'var(--brand-warm-white, #f0ead8)' }}>Account Settings</span>
</div>
<span style={s.welcomeText}>{hostName}</span>
</div>
{/* Change Password */}
<div style={s.settingsSection}>
<h2 style={s.sectionTitle}>Change Password</h2>
<form onSubmit={handleChangePassword} style={s.form}>
<div style={s.field}>
<label style={s.label} htmlFor="cur-pw">Current Password</label>
<input id="cur-pw" style={s.input} type="password" autoComplete="current-password"
value={currentPw} onChange={e => setCurrentPw(e.target.value)} disabled={savingPw} required />
</div>
<div style={s.field}>
<label style={s.label} htmlFor="new-pw">New Password</label>
<input id="new-pw" style={s.input} type="password" autoComplete="new-password"
value={newPw} onChange={e => setNewPw(e.target.value)} disabled={savingPw} required />
</div>
<div style={s.field}>
<label style={s.label} htmlFor="conf-pw">Confirm New Password</label>
<input id="conf-pw" style={s.input} type="password" autoComplete="new-password"
value={confirmPw} onChange={e => setConfirmPw(e.target.value)} disabled={savingPw} required />
</div>
{pwError && <p style={s.errorMsg}>{pwError}</p>}
{pwSuccess && <p style={s.successMsg}>{pwSuccess}</p>}
<button style={s.btnPrimary} type="submit" disabled={savingPw}>
{savingPw ? 'Saving…' : 'Update Password'}
</button>
</form>
</div>
{/* 2FA */}
<div style={{ ...s.settingsSection, marginTop: 24 }}>
<h2 style={s.sectionTitle}>Two-Factor Authentication</h2>
<p style={{ ...s.subText, margin: '0 0 12px' }}>
{twoFaEnabled
? 'Email 2FA is currently enabled. You receive a code by email each time you sign in.'
: 'Email 2FA is off. Enable it to receive a one-time code by email each time you sign in.'}
</p>
{twoFaMsg && <p style={twoFaEnabled ? s.successMsg : s.errorMsg}>{twoFaMsg}</p>}
<button
style={twoFaEnabled ? s.btnDanger : s.btnPrimary}
type="button"
onClick={handle2faToggle}
disabled={toggling2fa}
>
{toggling2fa ? '…' : twoFaEnabled ? 'Disable 2FA' : 'Enable 2FA'}
</button>
</div>
{/* Sign out */}
<div style={{ marginTop: 28, paddingTop: 20, borderTop: '1px solid var(--brand-border, #2a2518)' }}>
<button style={s.btnGhost} type="button" onClick={handleLogout}>Sign out</button>
</div>
</div>
</div>
)
}
// ── Portal (main upload screen) ────────────────────────────────────────────
return (
<div style={s.page}>
<div style={{ ...s.card, maxWidth: 560 }}>
{/* Header */}
<div style={s.header}>
<div style={s.logoRow}>
<span style={s.logoEmoji}>🎙️</span>
<span style={s.logoTitle}>Nuggets Upload Portal</span>
</div>
<div style={s.headerRight}>
<span style={s.welcomeText}>{hostName}</span>
<button style={s.btnIcon} title="Settings" onClick={() => setScreen('settings')}>⚙</button>
<button style={s.btnGhost} onClick={handleLogout}>Sign out</button>
</div>
</div>
{/* Announcement banner */}
{announcement && (
<div style={s.announcement}>
<span style={{ marginRight: 6 }}>📢</span>{announcement}
</div>
)}
{!seafileConfigured && (
<div style={s.warning}>
Seafile is not configured yet — contact the admin before uploading.
</div>
)}
{/* Success banner */}
{lastSuccess && (
<div style={s.successBanner}>
<p style={{ margin: '0 0 4px', fontWeight: 700, color: 'var(--brand-gold, #c9a84c)' }}>
✓ Episode submitted successfully
</p>
<p style={{ margin: '0 0 6px', fontSize: '0.87rem', color: '#c8c0ac' }}>
{lastSuccess.episodeName} · {lastSuccess.filename}
</p>
{lastSuccess.seafileUrl && (
<a href={lastSuccess.seafileUrl} target="_blank" rel="noopener noreferrer" style={s.seafileLink}>
View in Seafile →
</a>
)}
</div>
)}
{/* Recording guidelines (collapsible) */}
<div style={s.collapsible}>
<button
style={s.collapsibleToggle}
type="button"
onClick={() => setGuidelinesOpen(o => !o)}
>
<span>📋 Recording Guidelines</span>
<span style={{ fontSize: '0.9rem', opacity: 0.7 }}>{guidelinesOpen ? '▲' : '▼'}</span>
</button>
{guidelinesOpen && (
<div style={s.collapsibleBody}>
<ul style={{ margin: 0, paddingLeft: '1.2rem', lineHeight: 1.6 }}>
{guidelines.map((line, i) => <li key={i}>{line}</li>)}
</ul>
</div>
)}
</div>
{/* Upload form */}
<form onSubmit={handleSubmit} style={s.form}>
<div style={s.field}>
<label style={s.label} htmlFor="ep-name">Episode Name <span style={s.req}>*</span></label>
<input id="ep-name" style={s.input} type="text"
placeholder="e.g. The Grace of God in Romans 5"
value={episodeName} onChange={e => setEpisodeName(e.target.value)} disabled={uploading} required />
</div>
<div style={s.field}>
<label style={s.label} htmlFor="ep-passage">Passage <span style={s.req}>*</span></label>
<input id="ep-passage" style={s.input} type="text"
placeholder="e.g. Romans 5:1-11"
value={passage} onChange={e => setPassage(e.target.value)} disabled={uploading} required />
</div>
<div style={s.field}>
<label style={s.label} htmlFor="ep-desc">Description</label>
<textarea id="ep-desc" style={{ ...s.input, resize: 'vertical', minHeight: 80 } as React.CSSProperties}
placeholder="A short summary of what this episode covers…"
value={description} onChange={e => setDescription(e.target.value)} disabled={uploading} rows={3} />
</div>
<div style={s.field}>
<label style={s.label}>Audio File <span style={s.req}>*</span></label>
<div
style={{
...s.dropZone,
...(dragging ? s.dropZoneActive : {}),
...(uploading ? s.dropZoneDisabled : {}),
...(file ? s.dropZoneHasFile : {}),
}}
onDragOver={e => { e.preventDefault(); if (!uploading) setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => !uploading && fileInputRef.current?.click()}
>
<input ref={fileInputRef} type="file" accept=".mp3,.wav,.m4a,.aac,.flac,.ogg,.mp4,.mov"
style={{ display: 'none' }} onChange={handleFileChange} />
{file ? (
<div style={s.fileInfo}>
<span style={s.fileIcon}>🎵</span>
<div>
<p style={s.fileName}>{file.name}</p>
<p style={s.fileSize}>{formatBytes(file.size)} — click to change</p>
</div>
</div>
) : (
<>
<span style={s.dropIcon}>{dragging ? '📂' : '⬆️'}</span>
<p style={s.dropLabel}>{dragging ? 'Drop to select' : 'Drop audio file here, or click to browse'}</p>
<p style={s.dropSub}>MP3, WAV, M4A, FLAC · max 500 MB</p>
</>
)}
</div>
{fileWarning && <p style={s.warnMsg}>{fileWarning}</p>}
</div>
{uploading && (
<div style={s.progressWrap}>
<div style={s.progressBar}>
<div style={{ ...s.progressFill, width: `${progress}%` }} />
</div>
<p style={s.progressLabel}>Uploading… {progress}%</p>
</div>
)}
{uploadError && <p style={s.errorMsg}>{uploadError}</p>}
<button style={{ ...s.btnPrimary, marginTop: 4 }} type="submit" disabled={uploading || !seafileConfigured}>
{uploading ? 'Uploading…' : 'Submit Episode'}
</button>
</form>
{/* Episode History (collapsible) */}
<div style={{ ...s.collapsible, marginTop: 12 }}>
<button style={s.collapsibleToggle} type="button" onClick={() => setHistoryOpen(o => !o)}>
<span>🕐 Your Upload History</span>
<span style={{ fontSize: '0.9rem', opacity: 0.7 }}>{historyOpen ? '▲' : '▼'}</span>
</button>
{historyOpen && (
<div style={s.collapsibleBody}>
{history.length === 0 ? (
<p style={{ margin: 0, color: 'var(--brand-muted, #b0a48c)', fontSize: '0.85rem' }}>No uploads yet.</p>
) : (
history.map((entry, i) => (
<div key={i} style={{ padding: '6px 0', borderBottom: i < history.length - 1 ? '1px solid var(--brand-border, #2a2518)' : undefined }}>
<p style={{ margin: '0 0 2px', fontWeight: 600, fontSize: '0.87rem', color: 'var(--brand-warm-white, #f0ead8)' }}>
{entry.episodeName}
</p>
<p style={{ margin: 0, fontSize: '0.78rem', color: 'var(--brand-muted, #b0a48c)' }}>
{entry.passage} · {entry.filename} · {new Date(entry.uploadedAt).toLocaleDateString()}
</p>
</div>
))
)}
</div>
)}
</div>
</div>
</div>
)
}
// ── Styles ────────────────────────────────────────────────────────────────
const s: Record<string, React.CSSProperties> = {
page: {
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem 1rem',
background: 'var(--brand-black, #0a0a08)',
},
card: {
background: 'var(--brand-black-2, #0f0f0c)',
border: '1px solid var(--brand-border, #2a2518)',
borderRadius: 16,
padding: '2.25rem 2rem',
width: '100%',
maxWidth: 420,
boxShadow: '0 4px 24px rgba(0,0,0,0.4)',
},
logoRow: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 },
logoEmoji: { fontSize: '1.5rem' },
logoTitle: { fontWeight: 700, fontSize: '1rem', color: 'var(--brand-gold, #c9a84c)' },
heading: { fontSize: '1.4rem', fontWeight: 700, margin: '0 0 6px', color: 'var(--brand-warm-white, #f0ead8)' },
subText: { color: 'var(--brand-muted, #b0a48c)', fontSize: '0.88rem', margin: '0 0 1.4rem' },
header: {
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: 20,
gap: 12,
flexWrap: 'wrap',
},
headerRight: { display: 'flex', alignItems: 'center', gap: 8, paddingTop: 2 },
welcomeText: { fontSize: '0.85rem', color: 'var(--brand-muted, #b0a48c)', fontWeight: 500 },
btnGhost: {
background: 'none',
border: '1px solid var(--brand-border, #2a2518)',
borderRadius: 6,
padding: '0.3rem 0.65rem',
fontSize: '0.8rem',
cursor: 'pointer',
color: 'var(--brand-muted, #b0a48c)',
},
btnIcon: {
background: 'none',
border: '1px solid var(--brand-border, #2a2518)',
borderRadius: 6,
padding: '0.3rem 0.55rem',
fontSize: '0.95rem',
cursor: 'pointer',
color: 'var(--brand-muted, #b0a48c)',
lineHeight: 1,
},
btnPrimary: {
background: 'var(--brand-gold, #c9a84c)',
color: '#0a0a08',
border: 'none',
borderRadius: 8,
padding: '0.7rem 1rem',
fontWeight: 700,
fontSize: '0.95rem',
cursor: 'pointer',
width: '100%',
},
btnDanger: {
background: '#8b2020',
color: '#f0ead8',
border: 'none',
borderRadius: 8,
padding: '0.7rem 1rem',
fontWeight: 700,
fontSize: '0.95rem',
cursor: 'pointer',
width: '100%',
},
announcement: {
background: 'rgba(201,168,76,0.1)',
border: '1px solid var(--brand-gold, #c9a84c)',
borderRadius: 8,
padding: '0.65rem 0.9rem',
fontSize: '0.87rem',
marginBottom: 16,
color: 'var(--brand-warm-white, #f0ead8)',
},
warning: {
background: 'rgba(200,169,110,0.1)',
border: '1px solid #8a6e28',
borderRadius: 8,
padding: '0.6rem 0.85rem',
fontSize: '0.84rem',
marginBottom: 16,
color: '#e0c070',
},
successBanner: {
background: 'rgba(50,120,50,0.15)',
border: '1px solid #3a6a3a',
borderRadius: 10,
padding: '0.85rem 1rem',
marginBottom: 16,
},
seafileLink: {
color: 'var(--brand-gold, #c9a84c)',
fontSize: '0.84rem',
textDecoration: 'none',
fontWeight: 600,
},
form: { display: 'flex', flexDirection: 'column', gap: 14 },
field: { display: 'flex', flexDirection: 'column', gap: 5 },
label: { fontSize: '0.85rem', fontWeight: 600, color: 'var(--brand-warm-white, #f0ead8)' },
req: { color: '#e07070' },
input: {
padding: '0.55rem 0.75rem',
border: '1px solid var(--brand-border, #2a2518)',
borderRadius: 8,
fontSize: '0.93rem',
background: 'var(--brand-black-3, #1a1a15)',
color: 'var(--brand-warm-white, #f0ead8)',
outline: 'none',
width: '100%',
boxSizing: 'border-box' as const,
fontFamily: 'inherit',
},
dropZone: {
border: '2px dashed var(--brand-border, #2a2518)',
borderRadius: 10,
padding: '1.4rem 1rem',
textAlign: 'center' as const,
cursor: 'pointer',
transition: 'border-color 0.15s, background 0.15s',
userSelect: 'none' as const,
color: 'var(--brand-warm-white, #f0ead8)',
},
dropZoneActive: { borderColor: 'var(--brand-gold, #c9a84c)', background: 'rgba(201,168,76,0.06)' },
dropZoneDisabled: { cursor: 'default', opacity: 0.5 },
dropZoneHasFile: { borderColor: 'var(--brand-gold, #c9a84c)', background: 'rgba(201,168,76,0.04)' },
dropIcon: { fontSize: '1.8rem', display: 'block', marginBottom: 6 },
dropLabel: { fontWeight: 600, margin: '0 0 3px', fontSize: '0.9rem' },
dropSub: { color: 'var(--brand-muted, #b0a48c)', fontSize: '0.8rem', margin: 0 },
fileInfo: { display: 'flex', alignItems: 'center', gap: 12, textAlign: 'left' as const },
fileIcon: { fontSize: '1.8rem', flexShrink: 0 },
fileName: { fontWeight: 600, fontSize: '0.88rem', margin: '0 0 2px', wordBreak: 'break-all' as const, color: 'var(--brand-warm-white, #f0ead8)' },
fileSize: { color: 'var(--brand-muted, #b0a48c)', fontSize: '0.78rem', margin: 0 },
progressWrap: { display: 'flex', flexDirection: 'column' as const, gap: 5 },
progressBar: { height: 7, background: 'var(--brand-black-3, #1a1a15)', borderRadius: 4, overflow: 'hidden' },
progressFill: { height: '100%', background: 'var(--brand-gold, #c9a84c)', borderRadius: 4, transition: 'width 0.2s' },
progressLabel: { fontSize: '0.82rem', color: 'var(--brand-muted, #b0a48c)', margin: 0, textAlign: 'center' as const },
errorMsg: { color: '#e07070', fontSize: '0.84rem', margin: '2px 0' },
warnMsg: { color: '#e0c070', fontSize: '0.82rem', margin: '2px 0' },
successMsg: { color: '#6db86d', fontSize: '0.84rem', margin: '2px 0' },
collapsible: {
border: '1px solid var(--brand-border, #2a2518)',
borderRadius: 10,
marginBottom: 4,
overflow: 'hidden',
},
collapsibleToggle: {
width: '100%',
background: 'none',
border: 'none',
padding: '0.75rem 1rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
color: 'var(--brand-warm-white, #f0ead8)',
fontSize: '0.87rem',
fontWeight: 600,
fontFamily: 'inherit',
textAlign: 'left' as const,
},
collapsibleBody: {
padding: '0.75rem 1rem',
fontSize: '0.84rem',
color: 'var(--brand-muted, #b0a48c)',
borderTop: '1px solid var(--brand-border, #2a2518)',
},
code: {
fontFamily: 'monospace',
background: 'rgba(255,255,255,0.06)',
padding: '0 3px',
borderRadius: 3,
fontSize: '0.82rem',
},
settingsSection: {},
sectionTitle: {
fontSize: '0.95rem',
fontWeight: 700,
margin: '0 0 12px',
color: 'var(--brand-warm-white, #f0ead8)',
},
}
+240 -1066
View File
File diff suppressed because it is too large. Load diff
+363
View File
@@ -0,0 +1,363 @@
import { useMemo, useState } from 'react'
export interface AdminQuestion {
id: string
submittedAt: string
firstName: string
email: string
question: string
answer: string
answeredAt: string | null
isApproved: boolean
approvedAt: string | null
topic?: string
reference?: string
notifyOnAnswer?: boolean
notifiedAt?: string
upvotes?: number
pinned?: boolean
}
type Tab = 'new' | 'ready' | 'published' | 'all'
const hasAnswer = (q: AdminQuestion) => Boolean(q.answer?.trim())
const statusOf = (q: AdminQuestion): Exclude<Tab, 'all'> => (!hasAnswer(q) ? 'new' : q.isApproved ? 'published' : 'ready')
const TAB_LABELS: Record<Tab, string> = {
new: 'Needs answer',
ready: 'Ready to publish',
published: 'Published',
all: 'All',
}
function formatDate(value: string | null | undefined) {
if (!value) return ''
const d = new Date(value)
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
async function call(url: string, method: string, body?: unknown) {
const res = await fetch(url, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
})
const data = await res.json().catch(() => ({})) as { question?: AdminQuestion; message?: string }
if (!res.ok) throw new Error(data.message ?? 'Something went wrong.')
return data
}
interface Props {
questions: AdminQuestion[]
onChange: (questions: AdminQuestion[]) => void
}
export function QuestionsAdmin({ questions, onChange }: Props) {
const counts = useMemo(() => ({
new: questions.filter(q => statusOf(q) === 'new').length,
ready: questions.filter(q => statusOf(q) === 'ready').length,
published: questions.filter(q => statusOf(q) === 'published').length,
all: questions.length,
}), [questions])
const [tab, setTab] = useState<Tab>(counts.new > 0 ? 'new' : 'all')
const [search, setSearch] = useState('')
const [selectedId, setSelectedId] = useState('')
const [checked, setChecked] = useState<Set<string>>(new Set())
const [adding, setAdding] = useState(false)
const [message, setMessage] = useState('')
const [busy, setBusy] = useState(false)
// Edits live in a draft for the open question until saved
const [draft, setDraft] = useState<{ id: string; question: string; answer: string; topic: string; reference: string } | null>(null)
const topics = useMemo(() => {
const tally = new Map<string, number>()
for (const q of questions) if (q.topic?.trim()) tally.set(q.topic.trim(), (tally.get(q.topic.trim()) ?? 0) + 1)
return [...tally.entries()].sort((a, b) => b[1] - a[1]).map(([t]) => t)
}, [questions])
const visible = useMemo(() => {
const needle = search.trim().toLowerCase()
return questions
.filter(q => tab === 'all' || statusOf(q) === tab)
.filter(q => !needle || [q.firstName, q.question, q.answer, q.topic ?? '', q.reference ?? ''].some(v => v.toLowerCase().includes(needle)))
}, [questions, tab, search])
const selected = questions.find(q => q.id === selectedId) ?? null
const current = selected && draft?.id === selected.id
? draft
: selected
? { id: selected.id, question: selected.question, answer: selected.answer ?? '', topic: selected.topic ?? '', reference: selected.reference ?? '' }
: null
const dirty = Boolean(selected && current && (
current.question !== selected.question || current.answer !== (selected.answer ?? '') ||
current.topic !== (selected.topic ?? '') || current.reference !== (selected.reference ?? '')
))
const open = (id: string) => {
if (dirty && !confirm('You have unsaved changes to this question. Discard them?')) return
setSelectedId(id)
setDraft(null)
setMessage('')
}
const edit = (patch: Partial<NonNullable<typeof draft>>) => {
if (!selected || !current) return
setDraft({ ...current, ...patch })
}
const replace = (q: AdminQuestion) => onChange(questions.map(item => (item.id === q.id ? { ...item, ...q } : item)))
// After publishing, move on to the next question waiting in the same queue
const advance = (fromId: string) => {
const list = visible.filter(q => q.id !== fromId)
const idx = visible.findIndex(q => q.id === fromId)
const next = list[Math.min(idx, list.length - 1)]
setSelectedId(next?.id ?? '')
setDraft(null)
}
const run = async (label: string, fn: () => Promise<void>) => {
setBusy(true)
setMessage('')
try {
await fn()
} catch (err) {
setMessage(err instanceof Error ? err.message : `Could not ${label}.`)
} finally {
setBusy(false)
}
}
const saveFields = () => run('save', async () => {
if (!selected || !current) return
const d = await call(`/api/admin-questions/${selected.id}`, 'PATCH', current)
if (d.question) replace(d.question)
setDraft(null)
setMessage('Saved.')
})
const publish = () => run('publish', async () => {
if (!selected || !current) return
if (current.question !== selected.question) await call(`/api/admin-questions/${selected.id}`, 'PATCH', { question: current.question })
const d = await call(`/api/admin-questions/${selected.id}/publish`, 'POST', { answer: current.answer, topic: current.topic, reference: current.reference })
if (d.question) {
const id = selected.id
replace(d.question)
advance(tab === 'published' || tab === 'all' ? '' : id)
setMessage(selected.notifyOnAnswer && selected.email && !selected.notifiedAt ? 'Published, and the asker was emailed.' : 'Published.')
}
})
const unpublish = () => run('unpublish', async () => {
if (!selected) return
const d = await call(`/api/admin-questions/${selected.id}/approve`, 'POST', { approved: false })
if (d.question) replace(d.question)
setMessage('Taken off the site.')
})
const togglePin = () => run('pin', async () => {
if (!selected) return
const d = await call(`/api/admin-questions/${selected.id}/pin`, selected.pinned ? 'DELETE' : 'POST')
if (d.question) replace(d.question)
})
const remove = (ids: string[]) => {
if (ids.length === 0) return
if (!confirm(`Delete ${ids.length === 1 ? 'this question' : `${ids.length} questions`} permanently?`)) return
return run('delete', async () => {
const gone: string[] = []
for (const id of ids) {
try { await call(`/api/admin-questions/${id}`, 'DELETE'); gone.push(id) } catch { /* keep going */ }
}
onChange(questions.filter(q => !gone.includes(q.id)))
setChecked(new Set())
if (gone.includes(selectedId)) { setSelectedId(''); setDraft(null) }
if (gone.length < ids.length) setMessage(`Deleted ${gone.length} of ${ids.length}.`)
})
}
const [manual, setManual] = useState({ firstName: '', email: '', question: '', answer: '', topic: '', reference: '', publish: false })
const addManual = () => run('add the question', async () => {
const d = await call('/api/admin-questions', 'POST', { ...manual, approve: manual.publish && Boolean(manual.answer.trim()) })
if (d.question) {
onChange([d.question, ...questions])
setSelectedId(d.question.id)
setDraft(null)
}
setManual({ firstName: '', email: '', question: '', answer: '', topic: '', reference: '', publish: false })
setAdding(false)
setMessage('Question added.')
})
const status = selected ? statusOf(selected) : null
return (
<section className="admin-panel-section" aria-label="Questions and answers">
<div className="admin-panel-head">
<h2>Bible Questions &amp; Answers</h2>
<p>Work through questions from the top: write an answer, set a topic, and publish. Published answers appear on the public Q&amp;A page right away. Pinned questions show first there and are featured on the home page.</p>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12, alignItems: 'center' }}>
{(Object.keys(TAB_LABELS) as Tab[]).map(t => (
<button key={t} type="button" className={tab === t ? 'btn-admin-save' : 'btn-admin-ghost'} onClick={() => setTab(t)}>
{TAB_LABELS[t]} ({counts[t]})
</button>
))}
<button type="button" className="btn-admin-add" style={{ marginLeft: 'auto' }} onClick={() => setAdding(a => !a)}>
{adding ? 'Cancel' : '+ Add question'}
</button>
</div>
{message && <p className="admin-stats-note" role="status">{message}</p>}
{adding && (
<div style={{ border: '1px solid var(--color-border, rgba(255,255,255,0.14))', borderRadius: 6, padding: 12, marginBottom: 16 }}>
<h3 style={{ marginTop: 0 }}>Add a question</h3>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<div className="admin-field" style={{ flex: 1, minWidth: 160 }}>
<label htmlFor="qa-new-name">First name</label>
<input id="qa-new-name" type="text" value={manual.firstName} onChange={e => setManual({ ...manual, firstName: e.target.value })} />
</div>
<div className="admin-field" style={{ flex: 1, minWidth: 200 }}>
<label htmlFor="qa-new-email">Email (optional)</label>
<input id="qa-new-email" type="email" value={manual.email} onChange={e => setManual({ ...manual, email: e.target.value })} />
</div>
</div>
<div className="admin-field">
<label htmlFor="qa-new-question">Question</label>
<textarea id="qa-new-question" rows={3} value={manual.question} onChange={e => setManual({ ...manual, question: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="qa-new-answer">Answer (optional)</label>
<textarea id="qa-new-answer" rows={4} value={manual.answer} onChange={e => setManual({ ...manual, answer: e.target.value })} />
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<div className="admin-field" style={{ flex: 1, minWidth: 160 }}>
<label htmlFor="qa-new-topic">Topic</label>
<input id="qa-new-topic" type="text" list="qa-topic-options" value={manual.topic} onChange={e => setManual({ ...manual, topic: e.target.value })} />
</div>
<div className="admin-field" style={{ flex: 1, minWidth: 160 }}>
<label htmlFor="qa-new-ref">Scripture reference</label>
<input id="qa-new-ref" type="text" placeholder="e.g. Titus 1:5-9" value={manual.reference} onChange={e => setManual({ ...manual, reference: e.target.value })} />
</div>
</div>
<label style={{ display: 'block', margin: '8px 0' }}>
<input type="checkbox" checked={manual.publish} onChange={e => setManual({ ...manual, publish: e.target.checked })} /> Publish right away (needs an answer)
</label>
<button type="button" className="btn-admin-save" onClick={addManual} disabled={busy || !manual.firstName.trim() || manual.question.trim().length < 5}>Add question</button>
</div>
)}
<datalist id="qa-topic-options">{topics.map(t => <option key={t} value={t} />)}</datalist>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(240px, 340px) 1fr', gap: 20, alignItems: 'start' }}>
<div>
<div className="admin-field" style={{ marginBottom: 8 }}>
<input type="search" placeholder="Search questions, answers, topics…" value={search} onChange={e => setSearch(e.target.value)} aria-label="Search questions" />
</div>
{checked.size > 0 && (
<div className="admin-bulk-toolbar" style={{ marginBottom: 8 }}>
<span>{checked.size} selected</span>
<button type="button" className="btn-admin-remove" onClick={() => remove([...checked])}>Delete</button>
<button type="button" className="btn-admin-reset" onClick={() => setChecked(new Set())}>Clear</button>
</div>
)}
{visible.length === 0 && <p className="admin-stats-note">{tab === 'new' ? 'Nothing waiting for an answer. 🎉' : 'No questions here.'}</p>}
{visible.map(q => (
<div
key={q.id}
style={{
display: 'flex', gap: 8, alignItems: 'flex-start', padding: '8px 10px', marginBottom: 6, borderRadius: 6,
border: `1px solid ${q.id === selectedId ? '#8a6d2f' : 'var(--color-border, rgba(255,255,255,0.14))'}`,
}}
>
<input
type="checkbox"
aria-label={`Select question from ${q.firstName}`}
checked={checked.has(q.id)}
onChange={e => setChecked(prev => { const next = new Set(prev); if (e.target.checked) next.add(q.id); else next.delete(q.id); return next })}
style={{ marginTop: 4 }}
/>
<button
type="button"
onClick={() => open(q.id)}
style={{ flex: 1, textAlign: 'left', background: 'transparent', border: 0, padding: 0, cursor: 'pointer', color: 'inherit', font: 'inherit' }}
>
<strong>{q.pinned ? '📌 ' : ''}{q.firstName || 'Someone'}</strong>
<span style={{ fontSize: '0.76rem', opacity: 0.65 }}> · {formatDate(q.submittedAt)}</span>
<div style={{ fontSize: '0.84rem', opacity: 0.85, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{q.question}</div>
<div style={{ fontSize: '0.74rem', opacity: 0.7, marginTop: 2 }}>
{tab === 'all' && <>{TAB_LABELS[statusOf(q)]} · </>}
{q.topic ? `${q.topic} · ` : ''}{q.notifyOnAnswer && !q.notifiedAt && !q.isApproved ? '✉ wants an email · ' : ''}{(q.upvotes ?? 0) > 0 ? `▲ ${q.upvotes}` : ''}
</div>
</button>
</div>
))}
</div>
<div>
{!selected || !current ? (
<p className="admin-stats-note">Select a question to answer it.</p>
) : (
<>
<p style={{ margin: '0 0 8px', fontSize: '0.86rem' }}>
From <strong>{selected.firstName || 'Someone'}</strong>
{selected.email && <> · <a href={`mailto:${selected.email}`}>{selected.email}</a></>}
{' '}· {formatDate(selected.submittedAt)} · <em>{TAB_LABELS[status!]}</em>
</p>
{selected.notifyOnAnswer && selected.email && (
<p className="admin-stats-note" style={{ marginTop: 0 }}>
{selected.notifiedAt ? `They were emailed on ${formatDate(selected.notifiedAt)}.` : 'They asked to be emailed when this is answered. Publishing sends it.'}
</p>
)}
<div className="admin-field">
<label htmlFor="qa-edit-question">Question</label>
<textarea id="qa-edit-question" rows={3} value={current.question} onChange={e => edit({ question: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="qa-edit-answer">Answer</label>
<textarea id="qa-edit-answer" rows={9} value={current.answer} onChange={e => edit({ answer: e.target.value })} />
<p className="admin-field-help">{current.answer.length} / 5000</p>
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<div className="admin-field" style={{ flex: 1, minWidth: 180 }}>
<label htmlFor="qa-edit-topic">Topic</label>
<input id="qa-edit-topic" type="text" list="qa-topic-options" value={current.topic} onChange={e => edit({ topic: e.target.value })} placeholder="e.g. Salvation" />
</div>
<div className="admin-field" style={{ flex: 1, minWidth: 180 }}>
<label htmlFor="qa-edit-ref">Scripture reference</label>
<input id="qa-edit-ref" type="text" value={current.reference} onChange={e => edit({ reference: e.target.value })} placeholder="e.g. Titus 1:5-9" />
</div>
</div>
{topics.length > 0 && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', margin: '0 0 12px' }}>
{topics.slice(0, 8).map(t => (
<button key={t} type="button" className={current.topic === t ? 'btn-admin-save' : 'btn-admin-ghost'} onClick={() => edit({ topic: t })}>{t}</button>
))}
</div>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
{status === 'published' ? (
<>
<button type="button" className="btn-admin-save" onClick={saveFields} disabled={busy || !dirty}>Save changes</button>
<button type="button" className="btn-admin-ghost" onClick={unpublish} disabled={busy}>Unpublish</button>
</>
) : (
<>
<button type="button" className="btn-admin-save" onClick={publish} disabled={busy || !current.answer.trim()}>Save &amp; publish</button>
<button type="button" className="btn-admin-ghost" onClick={saveFields} disabled={busy || !dirty}>Save draft</button>
</>
)}
<button type="button" className="btn-admin-ghost" onClick={togglePin} disabled={busy} title="Pinned questions show first on the Q&A page and are featured on the home page">{selected.pinned ? 'Unpin' : 'Pin & feature'}</button>
<button type="button" className="btn-admin-remove" onClick={() => remove([selected.id])} disabled={busy}>Delete</button>
{dirty && <span className="admin-stats-note">Unsaved changes</span>}
</div>
</>
)}
</div>
</div>
</section>
)
}
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more