238 Commits

Author SHA1 Message Date
nmemmert 0512d40fc6 Redesign resources page with PageBanner, featured download, and consistent page headers; v1.1.40
Publish Container / docker (push) Has been cancelled
- Replace hero study guide form with PageBanner + featured download card in library
- Add PageBanner component used consistently across all interior pages (Downloads, Episodes, About, Contact, Q&A, Finished Books)
- Remove duplicate headings/eyebrows that conflicted with banner titles on About, Contact, Q&A, Finished Books pages
- Compact download card sizing (padding, border-radius, image size)
- Add Books & Resources tabbed section with fallback to TruthForLife widget
- Fix server sanitizer to pass through 'resources' placement items without URL
- Add featured-badge and featured card styling for primary guide

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 11:03:43 -04:00
nmemmert 0fdf7f0f50 v1.1.39
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 08:49:32 -04:00
nmemmert 005da43738 Default book button label to 'Learn More' when using url instead of amazonUrl; v1.1.38
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 08:47:25 -04:00
nmemmert 24d7fd7cec Fix book card button to render when url is set but amazonUrl is not; v1.1.38
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 08:42:09 -04:00
nmemmert 889baf23ca Fix server sanitizer to allow books/recommended placements and linkUrl/linkLabel fields; v1.1.38
- Add 'books' and 'recommended' to the placement whitelist in sanitizeCustomLinks
- Allow items with no url when placement is books/recommended (they use amazonUrl)
- Preserve linkUrl and linkLabel through sanitizeCustomBlocks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 08:23:59 -04:00
nmemmert eb1d3ddd9c Add Books + editable Recommended Resources sections; improve content block links; v1.1.38
- CustomBlock: add linkUrl/linkLabel fields for a styled button below the body
- Body text auto-linkifies raw URLs into gold anchor tags
- New 'books' placement: Reading List grid on Downloads page (cover, desc, Amazon btn)
- New 'recommended' placement: editable Recommended Resources section on Downloads
  page; falls back to Truth For Life widget when empty
- Admin: Books and Recommended Resources panels added to Content nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 08:16:36 -04:00
nmemmert a35e04320a Remove production checklist and email/calendar/contacts app; v1.1.37
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-20 09:30:19 -04:00
nmemmert 2ba9c9f7f4 Add Fetch from BSB button to admin lesson editor; v1.1.36
Adds a one-click "Fetch from BSB" button in the lesson passage text field
that calls the BSB API and overwrites the field with verse-numbered text.
Also fixes reference parsing to tolerate leading book abbreviations like
t:1-2 or col:1-2 by stripping letters before the chapter:verse pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-10 10:23:48 -04:00
nmemmert 9e602e29ec Fetch BSB passage text from Bolls.life API in study lesson view; v1.1.35
Adds /api/bible-passage server route that proxies the Berean Standard Bible
from Bolls.life per-verse, with in-memory caching. Study lesson scripture
block now displays live BSB text instead of static passageText. Adds
bibleBook field to StudyProgram so admins can set the book name explicitly
when the study slug doesn't match (e.g. a study titled "ephesians-part-2").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-10 10:12:25 -04:00
nmemmert 6acc3f1d04 Analytics: 3-tab layout + fix geographic data loss; v1.1.34
Splits the analytics panel into Traffic / Engagement / System tabs so
episode plays, engagement metrics, and contact/study data are no longer
buried under the visitor log.

Fixes a bug where topCountries, topStates, topCities, device breakdown,
and referrers were computed from only the 100 most-recent visitor rows
instead of all rows in the selected range — causing older geographic data
(e.g. visits from India, Texas, Georgia) to silently disappear from the
aggregate totals.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-10 09:22:47 -04:00
nmemmert bf3154378b Track all link clicks (internal + external) in analytics; v1.1.33
Replaces outbound-only tracking with a useLinkClickTracking hook that
captures every <a> click on the site. Stores as link_click events keyed
by destination URL, with an internal flag. Admin analytics page gains a
Link Clicks table sorted by count, showing type (internal/external).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-10 09:11:53 -04:00
nmemmert abb4c87c41 Update runtime data: analytics events, episode plays, study user state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-10 09:03:00 -04:00
nmemmert 441b907ae1 Fancy 404 page with book icon, action buttons, and broken-link report form; v1.1.32
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-10 08:56:10 -04:00
nmemmert 76239ee83b Fix redirects broken by service worker navigate fallback; v1.1.31
The SW's navigateFallback was using a denylist, so any path not explicitly
excluded (e.g. /spotify, /apple, /amazon, custom admin redirects) got served
the cached index.html instead of reaching the server's redirect handler.

Switch to navigateFallbackAllowlist: the SW only intercepts known SPA routes;
all other paths hit the network so server-side 301/302 redirects work correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-10 08:49:15 -04:00
nmemmert c56e468115 Revert "Add student birthday tracking with celebration banners and admin notification; v1.1.31"
This reverts commit c0a79b9ed0.
2026-08-10 08:43:53 -04:00
nmemmert c0a79b9ed0 Add student birthday tracking with celebration banners and admin notification; v1.1.31
- Admin can set birthday (month/day) per student in Study Users panel
- Summer birthdays (Jun–Aug) prompt admin to set an alternate school-year date
- 🎂 badge on student card header during birthday week
- Dismissible happy birthday banner shown to student on their study hub
- Admin receives email notification on the celebration day, once per year

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-10 08:42:52 -04:00
nmemmert 6a4399f7a6 Fix missing queueStudyUsersWrite import causing crash-restart loop; v1.1.30
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-05 11:42:17 -04:00
nmemmert 78f0988d75 Narrow reminder window to exact day to prevent re-fires on restart; v1.1.29
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-05 11:29:14 -04:00
nmemmert f53dcdec4f Add admin control for hero podcast art image; v1.1.28
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-05 11:18:49 -04:00
nmemmert 4c9bbee58a Copy package.json into Docker runtime image so APP_VERSION reads correctly; v1.1.27
package.json was missing from the runtime stage, causing readPackageVersion()
to throw and fall back to 'unknown'. The git commit hash (from COMMIT_SHA env)
was the only identifier shown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 08:12:42 -04:00
nmemmert 111b559865 Add webhook outbound, Contact→Calendar, checklist automation; v1.1.26
- Webhook CRUD (GET/POST/DELETE /api/admin-webhooks, POST .../test) with
   Webhooks drawer in EmailPage; fires contact.new and reply.sent events
- Contact→Calendar: history panel shows checklist episodes within ±30 days
- Checklist automation: saveEdit auto-sets productionStatus (idea→scheduled
  for future dates, idea→published for past); ⚙ Tasks button generates
  Record (−14d) and Edit (−7d) calendar task events for scheduled episodes
- Audit log captures webhook-added, webhook-removed, webhook-test events
- Webhook infrastructure: state, disk persistence, fire-and-forget with
  AbortSignal.timeout(8000), loaded on startup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 08:03:30 -04:00
nmemmert f5c4557e9f Add Tier 2 remaining features: publishing pipeline, broadcasts, audit log, drip trigger, today banner; v1.1.25
- CalendarPage: productionStatus field on episodes (idea→recorded→edited→scheduled→published), color dot on chips, dropdown in edit popover; "publishing today" banner with draft announcement button
- EmailPage: Broadcasts drawer (plain-text → Resend Broadcasts API); Audit Log drawer showing activity history; em-status-msg--ok style
- ContactsPage: ▶ Drip button in edit mode syncs contact to Resend audience and triggers drip automation
- server: appendAuditEntry() logged on reply-sent, email-sent, submission-deleted, contacts-merged, contacts-imported, broadcast-sent, drip-triggered; GET /api/admin-audit-log; POST /api/admin-broadcasts/send; POST /api/admin-contacts/trigger-drip; AUDIT_LOG_FILE persisted to data/audit-log.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 07:52:07 -04:00
nmemmert bf16deea92 Add Tier 2 email features: AI draft, Calendar→Email announce, delivery status, engagement badges; v1.1.24
- EmailPage: useLocation to accept pre-filled compose from Calendar; delivery status pill on sent bubbles; AI Draft button calling ANTHROPIC_API_KEY endpoint
- CalendarPage: useNavigate + announceEpisode(); ✉ Announce button in episode edit popover
- ContactsPage: emailStatus types; unreplied badge; best delivery status pill; engagement score stars
- server/routes/contact.js: POST /api/admin-contact-submissions/:id/draft-reply → Anthropic claude-haiku-4-5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 17:10:25 -04:00
nmemmert e2c560a1ab Add contacts features: tags, history, CSV import/export, merge, last-contacted; fix CalendarPage unused var; v1.1.23
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 17:00:21 -04:00
nmemmert 5e43c41b2b Add recurring events, week/day view, drag-to-reschedule, iCal export, and start times to calendar; v1.1.22
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 16:46:45 -04:00
nmemmert ee7c783de8 Add email threading, snooze, bulk actions, scheduled send, stars, and attachments; v1.1.21
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 16:29:24 -04:00
nmemmert 57fe34ba47 Make re-engagement email tiers configurable from admin; v1.1.20
Day thresholds and per-tier subject/body/CTA are now editable in the
Email Templates panel. Tier keys are position-based (t1/t2/t3) so
changing day values never re-sends emails already fired.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 16:13:25 -04:00
nmemmert 2732730f5e Add study user re-engagement reminder emails; v1.1.19
Sends 7/14/30-day inactivity emails to study users who haven't logged in,
with one-click HMAC-signed unsubscribe and automatic re-arm on next login.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 15:56:09 -04:00
nmemmert d0c949070f Fix base64-encoded HTML email bodies not rendering; v1.1.18
Inbound emails from some clients (Outlook, mobile) send htmlBody as
base64. Decode it on the server before storing (future emails) and on
the client before passing to the iframe srcDoc (existing stored emails).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 12:28:21 -04:00
nmemmert a263f40178 Fix compose focus steal; add contact picker to To field; v1.1.17
- Focus effect now only fires when compose/reply first opens, not on every
  keystroke — typing in the To field no longer jumps focus to the body
- 👥 button next to To field opens a searchable contact picker dropdown;
  selecting a contact fills in email and name instantly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 12:15:45 -04:00
nmemmert f0635753f1 Add general calendar events (recording, social, task, general); nav links; v1.1.16
- Four event types on the calendar: General, Recording, Social, Task — each color-coded
- + Event button opens form with type picker, title, date, notes, reminder
- Task events have a Mark Complete toggle; completed events show strikethrough
- Edit/delete popover for each event
- Email reminders work for calendar events same as episodes
- Added Email/Calendar nav links to /contacts header
- Added Email link to /calendar header (already had Admin)
- /email already links to Contacts and Calendar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 11:26:48 -04:00
nmemmert de201ff356 Add email reminders to calendar episodes; v1.1.15
- Per-episode reminder select (1d/2d/3d/1wk/2wk before publish date)
- Server checks hourly; fires Resend email to admin on reminder day
- reminderSentAt persisted so reminders don't re-fire; cleared if date or setting changes
- Bell indicator on calendar chips; sent confirmation in edit popover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 11:12:17 -04:00
nmemmert 0161a0dacd Add Contacts and Calendar nav links to /email header; v1.1.14
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 11:00:40 -04:00
nmemmert bdafac2908 Fix arm64 Docker build crash (QEMU illegal instruction); v1.1.13
Run npm ci on the native build platform using --platform=\$BUILDPLATFORM in a
deps stage, then copy node_modules to the runtime image. Avoids running Node.js
under QEMU emulation, which crashes with SIGILL on arm64 cross-builds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 10:53:29 -04:00
nmemmert ce235ba9ef Add /contacts and /calendar pages; email signature settings; v1.1.12
- /contacts: standalone page with hybrid contact list (submissions + manual entry), inline edit, search, archive
- /calendar: monthly release scheduling calendar reading/writing podcast checklist episode dates
- /email settings: editable signature panel; signature persisted server-side and injected into outgoing emails
- Move contacts out of /admin panel (now links to /contacts route)
- Partial PATCH for contact submissions (name, notes, archived independently)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 10:12:52 -04:00
nmemmert 7670b44d27 Add /email route as standalone web email client; v1.1.11
Extracts the email inbox from /admin and builds it as a full-viewport
email client at /email with the same admin auth. Key improvements over
the old embedded panel:

- Full-height split-pane layout (sidebar list + detail pane)
- Subject line extracted and shown separately for inbound emails
- Inline reply composer inside the detail pane (no more page-jump)
- Compose-new button for outbound messages to arbitrary addresses
- Search/filter across name, email, subject, and body
- Unread dot indicators with localStorage tracking (marks read on open)
- Keyboard navigation: ↑/↓ or j/k to move, r to reply, e to archive, Esc to close
- Source badges (contact-form / inbound-email) on list items
- Sent history and reply templates accessible via footer drawers
- 30-second polling for new messages
- New POST /api/admin-email/compose server endpoint for outbound sends
- Dashboard card "Open Inbox" now links to /email

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 09:34:39 -04:00
nmemmert e75a6d0e15 Increase episode title size on /episodes player; v1.1.10
Bumped .latest-episode-player__title from 1rem to 1.65rem for legibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 14:54:49 -04:00
nmemmert ae179fb4e8 Remove Spotify iframe branch from episode detail pages; v1.1.9
Individual episode pages always use EpisodeAudioPlayer — the Spotify
iframe check is removed since all per-episode embeds use direct audio URLs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 14:46:03 -04:00
nmemmert 021fe24c7e Separate /episodes player from per-episode player; v1.1.8
- EpisodeAudioPlayer restored to original horizontal layout (unchanged
  for individual episode detail pages chosen in /admin)
- LatestEpisodePlayer on /episodes page gets its own markup + CSS
  (.latest-episode-player__*) with the centered layout A — large gold
  play button, skip-back 15s / skip-forward 30s, full-width scrubber

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 14:42:08 -04:00
nmemmert 939e7b9da4 Restore Spotify iframe on individual episode detail pages; v1.1.7
The per-episode player (chosen in /admin) reverts to the Spotify embed
iframe — only the /episodes listing page uses the new custom player.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 14:37:25 -04:00
nmemmert 1e24eaa9af Replace Spotify show embed on /episodes with custom player; v1.1.6
LatestEpisodePlayer fetches the most recent episode from /api/episodes
(Anchor RSS) and renders it with the centered cinematic layout. The
Spotify show iframe is removed; platform buttons and HeadlinerWidget
remain below as before.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 14:36:41 -04:00
nmemmert 5d4ba7e593 Replace Spotify iframe with custom centered player (layout A); v1.1.5
- EpisodeAudioPlayer full variant: centered title, large gold play button,
  skip-back 15s / skip-forward 30s flanking controls, full-width scrubber
- Compact variant (study sections) unchanged
- Episode detail page fetches direct Anchor MP3 URL from /api/episodes by
  title match; falls back to resolvedEmbedUrl while loading
- Plays stream from Anchor CDN so stats still count

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 14:32:05 -04:00
nmemmert 71ff0e6842 Fix QR image not appearing for new codes; v1.1.4
Switch from toDataURL (which silently failed in browser) to toCanvas on
a real <canvas> ref — more reliable and renders immediately on mount.
Download PNG link appears once the canvas is drawn.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 14:12:57 -04:00
nmemmert 4e7905ae44 Show QR image and Download PNG button in QR Codes panel; v1.1.3
Each QR code card now renders a 128px preview of the actual QR image
(pointing to /qr/<slug> on the current origin) and a Download PNG link
that saves it as qr-<label>.png. Uses the qrcode browser build with
@types/qrcode for TypeScript support.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 14:06:13 -04:00
nmemmert 8d767b131c Add Remove button to Contacts panel; v1.1.2
Grouped contacts now have a Remove button that deletes all submissions
for that email address in one click (with confirmation).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 13:58:48 -04:00
nmemmert bc025b5dab Admin reorganization: nav groups, streak/progress display in Study Users; v1.1.1
- Split "Manage" nav group into Audience, Insights, Tools
- Moved Email Templates into Configure group
- Added Testimonials to About jump nav
- Added RSS Feed tab to Podcast Hub
- Updated Study Users expanded card: shows last studied date, streak (current + longest), and per-study lesson completion count
- Streak pill shown in collapsed header when > 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 13:27:41 -04:00
nmemmert 1e4fe5f0e3 v1.1.0 — RSS feed, PWA, lesson comments, progress tracking + streaks
RSS Feed
- /feed.xml proxies the Anchor feed under the site's canonical domain
- Rewrites channel <link> and atom:link self-ref to the site URL
- Served with 30-min Cache-Control, reuses the existing episode cache

PWA
- vite-plugin-pwa installed; Workbox service worker auto-generated on build
- manifest.json inlined in vite.config.ts (name, icons, theme, standalone)
- pwa-192.png and pwa-512.png generated from existing book_icon.png
- StaleWhileRevalidate for /api/episodes and /api/questions; CacheFirst for images
- API, feed.xml, and uploads routes excluded from navigate fallback

Lesson Comments
- Import and wire StudySectionComments into ColossiansStudySectionPage
- Replaces the CommunityBoard in the Lesson Discussion section
- All routes and moderation already existed; only the render was missing

Progress Tracking + Streaks
- Mark-complete handler now records lastStudiedDate, currentStreak, longestStreak on the user record
- Streak increments on consecutive calendar days, resets on a gap
- /api/study-account/overview now returns streak fields
- Account page: 4-stat summary row (notes, streak 🔥, longest streak, member since)
- Per-study progress bars showing completedLessons/totalLessons with gold → green fill at 100%

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 13:19:25 -04:00
nmemmert 455f5c96b3 Bump to v1.0.14 2026-07-22 13:03:05 -04:00
nmemmert 38185e43b3 Remove dead shareToX/shareToFacebook functions and related state; v1.0.14
Fixes CI lint errors — both functions were replaced by shareImageTo
in the previous commit but not deleted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 13:02:54 -04:00
nmemmert 87090c1c19 Fix: mark approve handler async for await sendResendEmailWithRetry; v1.0.13
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 12:59:41 -04:00
nmemmert 7ca457bb04 Consolidate Q&A share buttons — X and Facebook now share image; v1.0.13
Replaced the separate text-link X/FB buttons and the duplicate image buttons
with a single X and Facebook button each that share the generated quote card.
Keeps button row to 5: Upvote, X Share, Facebook Share, Copy link, Save image.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 12:59:13 -04:00
nmemmert ff7eec9906 Add share-as-image to X and Facebook for Q&A cards; v1.0.12
On mobile: Web Share API opens native share sheet with the PNG attached,
letting users pick X, Facebook, Instagram, etc. directly.
On desktop: downloads the image and opens the platform compose window.
"Save image" download button kept alongside the two new share buttons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 12:57:21 -04:00
nmemmert 6444d6e0e3 Add Q&A answered email template to admin Email Templates panel; v1.0.11
- content.ts: add qaAnsweredEmailSubject/Body/CtaLabel/Signoff fields + defaults
- email.js: buildQuestionAnsweredEmailTemplate reads from cachedSiteContent
- AdminPage.tsx: add EmailTemplateCard entry for Q&A answered notification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 12:55:30 -04:00
nmemmert d74b7523d3 Add notify-on-answer email and save-as-image for Q&A; v1.0.10
- Contact form: notifyOnAnswer checkbox (shown for Bible Questions only)
- Contact route: stores notifyOnAnswer flag on question record
- Email: buildQuestionAnsweredEmailTemplate for branded notification
- Questions route: sends notification email on approve when notifyOnAnswer + email + answer are set
- Q&A section: Canvas API "Save as image" button generates 1080x1080 PNG quote card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 12:51:38 -04:00
nmemmert 43799b8c6d Make compact view visually distinct; v1.0.9
Add qa-cards--compact class to container when active. Compact mode now
tightens padding, reduces font sizes, hides related questions, and
reduces card gaps — making the density difference obvious.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 12:41:43 -04:00
nmemmert 02dcbc33c9 Move Q&A submit CTA to top of page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 12:39:48 -04:00
nmemmert b60604aa80 Add testimonials section and Q&A submit CTA; v1.0.8
- Testimonials: new Testimonial type, CMS field, admin UI under About,
  and TestimonialsSection rendered on homepage and About page (hidden when empty)
- Q&A: "Submit a Question →" CTA at bottom of Q&A page linking to /contact

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 12:37:46 -04:00
nmemmert a7c5906c7b Remove dead CSS for qa-top-nav and thanks-page Q&A overrides; v1.0.7
These rules became orphaned after converting QuestionsPage to a full site
layout and removing the back-to-site nav link from QASection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 12:02:07 -04:00
nmemmert 595a2c3ec4 Reverse podcast highlights order; convert Q&A to full page; v1.0.6
- Podcast highlights now display newest-first (reversed array)
- Q&A page uses full site layout (header, nav, footer) instead of card/modal wrapper
- Remove redundant "Back to Site" link from QASection now that header is present

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 11:58:34 -04:00
nmemmert 369a7944f0 Build JS on CI host, copy dist into Docker image for faster arm64 builds; v1.0.5
Removes the build stage from Dockerfile so npm install/build no longer run under
QEMU emulation — only the lightweight runtime stage runs per-platform, making
arm64 images build reliably.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 16:02:19 -04:00
nmemmert 3a055ba8b2 Add ARM64 support to Docker build (multi-arch); bump to v1.0.4
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 14:31:49 -04:00
nmemmert 178038d830 Remove unused boilerplate and stale data files; bump to v1.0.3
Deletes src/data/projects.ts (never imported), and the three unused
Vite boilerplate assets (react.svg, vite.svg, hero.png).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 08:20:33 -04:00
nmemmert f419e7d617 Delete Verse by Verse with Nate Complete Series directory 2026-07-16 08:17:52 -04:00
nmemmert 0e771bfbb5 Rewrite README to accurately describe the app; bump to v1.0.2
Replaces outdated portfolio/project-cards description with accurate
documentation of the podcast/ministry site: features, all env vars,
routes, API endpoints, persistent data paths, and admin capabilities.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 08:12:45 -04:00
nmemmert 288baae676 Fix version commit SHA showing as unknown in Docker (v1.0.1)
The runtime image doesn't include .git, so git rev-parse failed at
startup. Fix: pass COMMIT_SHA as a Docker build arg from github.sha
in CI and bake it into the image as an ENV var, which config.js
already prefers over running git.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 08:00:29 -04:00
nmemmert 172e4a9358 Add version numbering (v1.0.0)
- package.json: bump to 1.0.0
- server/config.js: export APP_VERSION (from package.json) and GIT_COMMIT
  (from COMMIT_SHA env var set by CI, or git rev-parse --short HEAD fallback)
- GET /api/version: new public endpoint returning { version, commit }
- GET /api/admin-auth/status: includes version and commit in response
- AdminPage sidebar: displays version string (e.g. "v1.0.0 (1d43875)")
  below the Log Out button, styled as muted metadata text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 07:55:52 -04:00
nmemmert 1d43875e5a Fix 11 bugs: restore crash, draft leak, email failures, memory leaks
Critical fixes:
- sanitizeLoadedHitStats/VisitorStats: restore full state shape so a
  snapshot restore no longer crashes hit-counting middleware (missing
  byPathReal, byPathBot, byDayReal, byDayBot, botReasons, ipHashIndex)
- /questions/share/🆔 read state.questions only, not draft questions
- inbound-email: validate date with Number.isFinite before toISOString
- study-reminders: wrap each send in try/catch so one failure doesn't
  block remaining users; persist sent-markers after each success

Security:
- getClientIp: use req.ip (trust-proxy-resolved) instead of raw
  x-forwarded-for header to prevent IP spoofing
- env-snapshot.env: delete immediately after backup tar stream ends
  so secrets don't linger on disk between exports

Correctness / UX:
- contact form: email failures no longer 500 the user after the
  submission is already saved; log and fall through instead
- study-account profile: cap data URI avatar at 6 MB
- admin enrollment PATCH: validate slug against study catalog
- signup: return 503 at MAX_STUDY_USERS instead of silently dropping
  oldest accounts

Memory leaks:
- contactHits, downloadHits Maps: prune stale entries at 5000 entries
- resendEmailSubmissionIndex: trim to 2000 entries (oldest first)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 07:48:54 -04:00
nmemmert f5e98826be Include env settings snapshot in full data backup
Docker deployments have no .env file — settings live in container env
vars — so the export now writes env-snapshot.env (dotenv format) into
the data dir from a whitelist of portable keys (admin password, Resend
keys, webhook URLs, etc.) before archiving. Machine-specific vars
(PORT, NODE_ENV, SITEFORGE_DATA_DIR, ALLOW_INSECURE_COOKIES, build
metadata) are excluded. Values are not applied automatically on
restore; the file documents what to set on the new server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:12:51 -04:00
nmemmert 0406cdb9dc Remove accept filter on full-backup restore file input
iPadOS greys out .tar.gz files when the input has an accept filter, so
the archive could not be selected. The server validates the gzip
signature and archive contents before restoring, so no client-side
filter is needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:00:47 -04:00
nmemmert 2e406af471 Add ALLOW_INSECURE_COOKIES env flag for HTTP access in production
Browsers drop Secure cookies on plain-http origins, so logging into the
admin over a LAN/VPN IP (e.g. during server migration, before TLS is in
front) silently failed every authenticated request. Setting
ALLOW_INSECURE_COOKIES=true omits the Secure flag; default behavior in
production is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:49:37 -04:00
nmemmert f4fd177421 Add full /data backup export/import for server migration
- New admin endpoints: GET /api/admin-backup/export streams the entire
  data directory as tar.gz (after flushing queued writes); POST
  /api/admin-backup/import validates the archive, snapshots current
  data, replaces the folder, and reloads all in-memory state.
- Admin UI: "Full Data Backup" section with download and restore-from-
  file controls in the Analytics panel.
- Fix admin TOTP secret path to honor SITEFORGE_DATA_DIR (moved base
  path resolution to server/paths.js to avoid a circular import), so
  2FA for admin and study users survives backup/restore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:08:23 -04:00
nmemmert 17c9cbbc8b Fix inbound-email data loss, MIME truncation, and header injection
- server/data.js: preserve source/htmlBody/inboundTo/messageId across
  server restarts (sanitizeLoadedContactSubmissions was silently
  dropping them on reload from disk)
- cloudflare/email-worker.js: rewrite MIME parsing to split on the
  actual boundary marker instead of any literal "--", unfold
  multi-line headers, and correctly recombine multi-byte UTF-8 in
  quoted-printable decoding
- server/routes/inbound-email.js: validate Message-ID against RFC 5322
  grammar before storing/using it, and compare the webhook secret with
  timingSafeEqual to match the rest of the codebase's auth checks
- server/routes/contact.js: re-validate messageId at the point it's
  injected into outgoing In-Reply-To/References headers; move the
  allowed reply-from addresses into a shared config constant
- src/AdminPage.tsx: 30s inbox poll now syncs field updates (e.g.
  archived) on already-loaded submissions instead of only appending
  new ones; consolidate the duplicated from-address list
- .claude/launch.json: add a vite dev server preview config used to
  verify these changes

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 08:21:50 -04:00
nmemmert fbb9a24f32 Add per-reply from-address selector and inbox address badges
- Reply composer shows a From dropdown (hello@ or nate@), defaulting
  to whichever address the inbound email was sent to
- Server validates the chosen address against an allowlist before sending
- Inbox list shows colored address badges: blue for hello@, purple for nate@

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 12:14:34 -04:00
nmemmert edc17b8710 Poll for new inbox messages every 30 seconds in admin
Silently checks for new contact submissions in the background and
prepends any new ones to the inbox without disrupting the current view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 12:01:09 -04:00
nmemmert 16947e305c Keep subject when applying reply template to preserve email threading
Templates now only fill the message body, not the subject, so the
Re: [original subject] line stays intact for proper Gmail threading.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 11:58:42 -04:00
nmemmert 77a4f9300f Thread admin replies to original email via In-Reply-To/References headers
When replying to an inbound email that has a messageId, the outgoing
Resend payload now includes In-Reply-To and References headers so the
reply threads correctly in Gmail and other email clients.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 11:36:11 -04:00
nmemmert 118314d0d7 Render inbound email HTML body in admin inbox
Worker now extracts both text/plain and text/html MIME parts and sends
htmlBody in the webhook payload. Server stores it on the submission.
Admin inbox renders htmlBody in a sandboxed iframe when present, falling
back to plain text with whitespace preserved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 11:32:52 -04:00
nmemmert 4b411f763f Add inbound email capture via Cloudflare Email Worker
- Cloudflare Email Worker forwards hello@ and nate@ to Gmail and POSTs
  parsed MIME email to /api/inbound-email as admin inbox entries
- New server route authenticates via shared secret and deduplicates by
  Message-ID before storing inbound emails as contact submissions
- Admin inbox shows "email" badge for inbound messages; reply composer
  opens blank with auto-subject "Re: [original]" and signature preview
- Documents setup steps in cloudflare/email-worker.js and .env.example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 11:23:27 -04:00
nmemmert f83682879b Remove archivedSeries from useGlobalSearch (field does not exist on SiteContent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 10:37:14 -04:00
nmemmert 25fa24afdf Add Finished Books feature with episode playlists and nav dropdown
Public:
- /finished page with ornament heading, intro text, book grid, and styled empty state
- /finished/:id episode playlist page filtered by RSS season number
- Episodes nav becomes click-toggle dropdown (Current Series / Finished Books)
- Finished Books link in footer
- Dropdown font fixed to match other nav links

Admin:
- End Current Series & Start New Book wizard on Current Series tab
- Finished Books management tab with add/edit/remove and image asset picker

Data:
- FinishedBook type + finishedBooks[] field on SiteContent
- RSS parser extracts itunes:season per episode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 10:34:43 -04:00
nmemmert 2eb98b66d0 Add Finished Books feature with episode playlists
- New FinishedBook type in content.ts + finishedBooks[] field on SiteContent
- RSS parser now extracts itunes:season into each episode object
- /finished grid page and /finished/:id playlist page (filters episodes by season)
- Episodes nav link replaced with dropdown: Current Series / Finished Books
- Finished Books link added to footer
- Admin: "End Current Series & Start New Book" wizard on Current Series tab
- Admin: Finished Books management tab (add/edit/remove entries)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 10:22:37 -04:00
nmemmert a8f6024234 Fix Truth For Life devotional widget not rendering
Replaced broken srcdoc iframe approach with direct script injection so
the TFL syndicate.js runs in page context with access to the DOM it needs.
Also added truthforlife.org and ajax.googleapis.com to CSP script-src,
which was silently blocking the widget and its jQuery dependency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 09:26:00 -04:00
nmemmert 2169739910 cert fix 2026-06-18 14:02:13 -04:00
nmemmert 83c3f554aa cert fix 2026-06-18 13:59:30 -04:00
nmemmert b77aba0167 build notes fix 2026-06-18 13:46:41 -04:00
nmemmert dd43d0c3ea notes upgrade 2026-06-18 13:43:17 -04:00
nmemmert e0ed798741 easter egg 2026-06-18 10:03:32 -04:00
nmemmert 9174331d2d Bug fixes (real breakage):
Newsletter nudge — was calling /api/study-account/profile (wrong endpoint, ignored subscription). Now correctly calls /api/study-account/preferences with PATCH.
Security:

Email regex — replaced the permissive [^\s@]+@[^\s@]+ pattern with a proper RFC-compliant regex in contact.js and downloads.js
Avatar magic bytes — server now checks actual PNG/JPEG/GIF/WEBP header bytes, not just the data URL prefix
Certificate rate limit — public /api/public/certificate/:token now has a 30 req/15min limiter
Session absolute TTL — admin sessions now have a 30-day hard cap; a stolen token can no longer be kept alive indefinitely by passive reads
Account lockout — 5 failed logins locks a study account for 1 hour
CSP headers — Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, and Referrer-Policy headers added globally
Data integrity:

Cascade delete — deleting a study account now also removes their certificates, community posts, comments, and progress file
UX / reliability:

Escape key on modals — all 3 modal groups (study index, notes, account) now close on Escape
Display name min-length — empty spaces-only names rejected; if provided, must be ≥2 chars
Note save rate limit — 30 saves/minute per user max
Analytics fetch timeout — 5s AbortController so a hanging server doesn't block the browser indefinitely
Email validation on signup — frontend catches bad email formats before hitting the server
Cleanup:

Deduplicated download forms — StudyDownloadForm and ResourceDownloadForm now share a single DownloadForm base; both are now thin wrappers
2026-06-18 09:35:44 -04:00
nmemmert fda9fa27a6 Update analytics cookie banner text
Clearer language explaining what's tracked and that no personal data
is sold or shared.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 14:39:54 -04:00
nmemmert 945a2e160e Redesign visitor log as readable cards replacing dense table
Each visit is now a card showing name/IP, page path, location, device,
visit count, new/returning tag, and timestamp at a glance. Logged-in users
show their display name prominently. Click to expand still shows full page
history and raw details. Adds a purple "Logged in" tag for study users.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 14:24:16 -04:00
nmemmert 31426fccba Add comprehensive visitor engagement tracking and scroll-to-top on navigation
Tracks scroll depth (25/50/75/90%), time on page, UTM parameters, outbound
link clicks, search queries, audio pause/completion/listen time, and 404s.
Logged-in study users are now tied to their visitor record and surfaced in
the admin recent visits table. Scroll position resets on every route change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 14:06:21 -04:00
nmemmert a6f0c39c35 Simplify episodes page and upgrade podcast highlights to card layout
- Episodes page now shows Spotify show embed only (no episode list)
- Podcast highlights section replaced plain links with episode-card style
  (gold border, episode number, title, summary, Listen → CTA)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 13:48:49 -04:00
nmemmert 8450d81e7f Add per-episode play tracking with bar chart in admin analytics
- POST /api/analytics/play records a play event (title + date) on first
  play per player mount, skipping admin sessions
- Plays persisted to data/episode-plays.json with total + byDay buckets
- Admin stats response includes episodePlays sorted by total plays
- AnalyticsPanel shows horizontal bar chart + summary cards for all
  episodes dynamically — new episodes appear automatically as played

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 13:39:35 -04:00
nmemmert 54bc7b28da Fix RSS feed URL to use correct Anchor s/ path
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 13:29:09 -04:00
nmemmert 1010cb1f33 Update study reminders timestamp and exclude admin sessions from pageview analytics
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 13:24:48 -04:00
nmemmert a1d5889711 Replace Spotify embeds with branded custom audio player backed by RSS
- Add /api/episode-audio route returning MP3 URLs from Anchor RSS feed
- Replace Spotify URL text inputs in admin with RSS episode dropdowns
- New EpisodeAudioPlayer component with podcast art, show name, progress
  bar, and Spotify icon link — full and compact sizes
- Backward-compatible: legacy Spotify embed URLs still render as iframes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 13:23:19 -04:00
nmemmert 3d8f02d915 Scope external site link styles to homepage section only
The .section-resources CSS overrides were breaking the Downloads page
resource link layout. Added .section-external-sites class to the
ExternalSitesSection and scoped all image/layout overrides to it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 12:04:49 -04:00
nmemmert d6acf85252 Fix Save Draft always showing Unsaved after save
The snapshot was set from buildSiteContentForSave(form) which runs
normalizeSiteContentForAdmin and rebuilds study objects, producing a
JSON string that never matched the raw form state. isDirty therefore
stayed true permanently after every save.

Fix by capturing JSON.stringify(form) before the fetch and using that
as the snapshot, so isDirty correctly clears on success.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 11:54:39 -04:00
nmemmert 68fa1497a3 Remove helper text from external sites section on homepage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 11:35:13 -04:00
nmemmert d42c00d6dd Show image on external site link cards on homepage
Adds the configured imageUrl as a full-width banner at the top of each
External Sites card, above the link name and description.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 11:18:08 -04:00
nmemmert 47887d7fbd Add admin-editable QR code image to homepage share section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 10:03:21 -04:00
nmemmert 48c2f81d00 Add QR code tracker to admin panel
Each QR code gets a /qr/<slug> redirect that logs scans (IP, user agent,
timestamp) to disk. The admin QR Codes view lets you add, edit, enable/disable,
and delete codes, with per-code scan history inline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 09:33:35 -04:00
nmemmert 37fc69680e Send study reminder emails 8 hours after lesson release
Lessons release at 1:00 AM, but reminders fired immediately, landing
around 9 PM the prior evening. Delay sends by 8 hours so they go out
at 9:00 AM on the release day.
2026-06-15 07:56:56 -04:00
nmemmert 1bc573147d Redesign Q&A for mobile and upgrade completion certificate
- Full mobile redesign of Q&A: edge-to-edge cards, full-width layout,
  wrapping action buttons, hidden sidebar, proper touch targets, and
  fixed double-padding from thanks-page/thanks-card wrapper
- Fancy certificate redesign: double gold border frame, corner ornaments,
  ornamental rules with diamond dividers, decorative seal, gold glow,
  and updated issuer to "Verse by Verse with Nate"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 13:40:31 -04:00
nmemmert 8eae361a73 Q&A mobile 2026-06-09 11:30:36 -04:00
nmemmert ee9d76ada7 Wire lesson discussion to community, fix search links
- Replace standalone StudySectionComments with CommunityBoard locked to
  section tag on lesson page — lesson discussion now feeds directly into
  the community system
- Quiz "share to discussion" posts to community API (not separate comments)
- "Go to Community" button no longer pre-filters by section (lesson
  discussion is now on-page)
- Add "See all community posts" link from lesson discussion block
- Fix search: download results now use correct custom-- ID format
- Fix search: episodes with a highlight page route to /episodes/:id
  internally instead of opening the external link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:23:54 -04:00
nmemmert 75d490020b search download fix 2026-06-09 10:02:01 -04:00
nmemmert c4645e3475 Add cross-site improvements across three phases
Phase 1 — Quick wins:
- Image lazy-loading on series/resource cards
- Newsletter signup added to Episodes page (before highlights)
- Per-route meta tags via usePageMeta hook (title, og:title, og:description)
- Breadcrumbs on study index, section, and notes pages
- SVG completion checkmark badges on study section list
- Analytics time-range filter (7d / 30d / 90d) in admin panel

Phase 2 — Medium features:
- Related episodes on archived series detail pages
- Resource library two-tier filter (type + tag chips)
- Global search (Fuse.js) moved below sticky header as full-width bar
- Q&A anonymous upvoting with localStorage dedup + admin pin/unpin
- Study enrollment funnel tracking (firstVisitAt, firstCompletionAt) with funnel chart in analytics

Phase 3 — Larger features:
- Study section comments (auto-approve for enrolled users, admin moderation panel)
- Study completion certificate (canvas render, PNG download, shareable public URL)
- Episode script full-text search (mammoth docx extraction, server-side search, admin upload UI)
- Reflection questions renamed from Discussion Questions; quiz answers can be shared to section discussion
- Public certificate route at /certificate/:token with og meta tags
- Comment moderation panel added to admin under Manage > Study Comments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 09:10:29 -04:00
nmemmert 9ad24df626 tweaks 2026-06-08 16:47:21 -04:00
nmemmert 3e1b1e0fae Enrolment issue 2026-06-08 14:45:04 -04:00
nmemmert 95b7a3f211 Add in /salvation 2026-06-08 10:24:37 -04:00
nmemmert 66699270f5 Clean. Here's what's live now:
- **Tag filter bar** appears above the grid whenever any download has tags assigned. Chips for "All" + each unique tag, centered under the heading.
- **Clicking a tag** filters the grid to only matching downloads. Clicking the active tag again (or "All") clears the filter.
- **Empty state** — if a tag has no results it shows a friendly message instead of a blank grid.
- The filter only shows when tags exist — so it's invisible today (your 3 current downloads have no tags) and appears automatically once you start tagging items in the admin.

To use it: go to Admin → Downloads → edit a download item → add tags like `Colossians`, `Worksheet`, `Devotional`. Those tags will show up as filter chips on the public page instantly after publishing.
2026-06-05 09:23:55 -04:00
nmemmert c80804b055 checpoint fix 2026-06-04 10:35:01 -04:00
nmemmert 285d837a0b publish error fix 2026-06-04 10:22:07 -04:00
nmemmert 0ba35e0e52 font change 2026-06-04 10:12:13 -04:00
nmemmert bccd1cfa60 TFL Resources 2026-06-04 09:46:51 -04:00
nmemmert 8d95c50041 TFL fix 2026-06-04 09:36:04 -04:00
nmemmert 20397960d6 Truth for life fix 2026-06-04 09:23:11 -04:00
nmemmert cb78b6522d All green. You're good to build and deploy. Here's a summary of everything done since your last commit:
---

**🐛 Bugs fixed**
- Study hub/lesson pages turned gold — CSS merge error with `.study-index-page` fixed
- Lesson announcements not saving — `announcement` field missing from `sanitizeColossiansStudySections` whitelist
- 2FA email method blocked with "not configured" — guard checked `totpSecret` instead of `twoFaMethod`
- `STUDY_REMINDERS_FILE` constant never defined — server would crash on first reminder write
- `sanitizeStudyUsers` stripped all 2FA fields — data lost on every restart
- `scheduleStudyReminders` missing from refactored modules

** Features added**
- Commentary supports paragraph formatting (double newline = new paragraph)
- Lesson announcement banner — full-width gold stripe at top of lesson page
- "Take the Quiz" renamed to "Discussion Questions" throughout
- Students must complete checkpoint before unlocking next lesson (lesson list + Next Lesson button both gate on it)
- No checkpoint questions → simple Mark as Completed button as fallback
- Truth For Life syndication widget on homepage
- Newsletter welcome email updated to your new HTML template

**🔧 Maintenance**
- `server.js` refactored from 6,013 lines into 12 focused modules
2026-06-04 09:17:34 -04:00
nmemmert 07519b4f48 auth fix 2026-06-03 16:14:49 -04:00
nmemmert e11858c2f3 gated lessions 2026-06-03 16:07:54 -04:00
nmemmert a7dc3ede21 remove quiz label 2026-06-03 15:56:17 -04:00
nmemmert b34a78f874 fix agian 2026-06-03 15:49:22 -04:00
nmemmert ae396da72e annoucments 2026-06-03 15:27:21 -04:00
nmemmert 346ecb217c refractor server.js 2026-06-03 15:08:14 -04:00
nmemmert e17a818deb fix 2026-06-03 14:31:22 -04:00
nmemmert 68f6212e9b cool stuff 2026-06-03 14:14:25 -04:00
nmemmert a2c4924191 email templates 2026-06-03 13:14:08 -04:00
nmemmert 49c9994156 New admin panel - People → Study Users 2026-06-03 12:29:05 -04:00
nmemmert 4983524ea4 newsletter fix 2026-06-03 12:08:53 -04:00
nmemmert e561bfa000 analytics fixs 2026-06-03 11:35:45 -04:00
nmemmert b7aa32e4b7 questions fix 2026-06-03 11:18:22 -04:00
nmemmert 3e49ccce62 fix 2026-06-03 11:07:46 -04:00
nmemmert 617998656c Merge pull request #4 from nmemmert/feature/online-class-upgrade
feat: homepage newsletter, Q&A callout, episodes page redesign, SEO/s…
2026-06-03 10:58:06 -04:00
nmemmert b187b19f18 feat: homepage newsletter, Q&A callout, episodes page redesign, SEO/sitemap, asset tagging
- Fix "Invalid Date" on student account page (memberSince guard)
- Fix study community author showing full email instead of username
- Add newsletter sign-up section to homepage (between PRISM and Current Series)
- Add newsletter opt-in nudge to study enrollment flow
- Redesign episodes page: latest episode featured card + per-episode Spotify embed
- Add SEO description field to episodes page (visually hidden, admin-editable)
- Expand default sitemap paths to include /about, /contact, /episodes, /resources, /study, /study/titus, /study/colossians
- Add featured Q&A callout section to homepage
- Add Q&A callout to study hub footer
- Tag both untagged image assets in Asset Manager
2026-06-03 10:56:55 -04:00
nmemmert ef6760c788 Merge pull request #3 from nmemmert/feature/online-class-upgrade
fix
2026-06-02 14:21:44 -04:00
nmemmert 01abb2c8c6 fix 2026-06-02 14:21:03 -04:00
nmemmert bdd80db80a Merge pull request #2 from nmemmert/feature/online-class-upgrade
Feature/online class upgrade
2026-06-02 14:15:26 -04:00
nmemmert 3e050c8909 stuff 2026-06-02 14:13:37 -04:00
nmemmert 5b448aef0f stuff 2026-06-02 14:02:00 -04:00
nmemmert b18f4e88fa Add study completion, checkpoints, lesson announcements, and lesson-thread defaults 2026-06-02 12:03:29 -04:00
nmemmert 33ac2fb5d0 Fix homepage external link placement and publish sanitization 2026-06-01 13:31:26 -04:00
nmemmert 1d536827e3 Improve admin UX and autosave handling 2026-06-01 12:56:26 -04:00
nmemmert b9ed8761db admin redesign: topbar state zone, podcast hub tabs, AnalyticsPanel CSS classes, useAutosave 2026-06-01 11:45:52 -04:00
nmemmert a58d75c425 Add drag-and-drop lesson reordering for admin study sections 2026-05-26 10:09:43 -04:00
nmemmert c1fcf66ef8 Expand study lesson and community pages to use more horizontal space, reduce scrolling 2026-05-21 16:09:42 -04:00
nmemmert b079eb5386 Fix community page auth endpoint to correctly load enrollment status 2026-05-21 14:53:11 -04:00
nmemmert 295c0b61f8 Default to login mode and make signup toggle a prominent button 2026-05-21 14:51:59 -04:00
nmemmert 01143d3ec9 Add community question message type with pre-fill from study community 2026-05-21 14:39:30 -04:00
nmemmert 367ebc88b4 Add dedicated study community page at /study/:studySlug/community 2026-05-21 14:07:17 -04:00
nmemmert eee200cf9c Add study community discussion board and Ask Nate button 2026-05-21 14:04:12 -04:00
nmemmert ffa860d5bc Preserve releasedAt timestamps during admin content sanitize 2026-05-21 12:44:13 -04:00
nmemmert bd047b029c Fix admin publish data loss for unreleased lessons and harden data persistence 2026-05-21 12:39:28 -04:00
nmemmert da5f77c3c6 Generalize study release-date gating across all studies 2026-05-21 12:29:57 -04:00
nmemmert 74e9f57ea5 Add admin study user enrollment list 2026-05-21 12:08:15 -04:00
nmemmert 376088a52b Add study enrollment manager, profile settings, and email verification flow 2026-05-21 11:46:49 -04:00
nmemmert 67f3caf40b Add editable PRISM homepage section 2026-05-20 09:00:22 -04:00
nmemmert c66be30b20 changed checklist order and added what is next when collaped. 2026-05-18 08:47:24 -04:00
nmemmert f14992350c adding 2026-05-18 08:27:24 -04:00
nmemmert db762c5a05 Add admin podcast checklist UX and persistence updates 2026-05-18 08:26:53 -04:00
nmemmert dba5f16bcd Load draft content in admin while keeping public lesson locks 2026-05-12 15:23:17 -04:00
nmemmert da35006a30 Backfill release dates on startup and hard-lock undated lessons 2026-05-12 15:17:08 -04:00
nmemmert d74151d91d Enforce study lesson release locks and schedule biweekly Monday dates 2026-05-12 15:09:38 -04:00
nmemmert 82e9443db8 Remove study sidebar and add account/login CTA button 2026-05-12 13:50:42 -04:00
nmemmert af0c33282a Improve sidebar spacing and layout - push sidebar right, add visual divider for next tracks 2026-05-12 13:45:41 -04:00
nmemmert 3e38785709 Move Why Sign Up section to sticky sidebar on study landing page 2026-05-12 13:38:07 -04:00
nmemmert c91e23a0bb Enhance study hero section prominence with larger typography and visual hierarchy 2026-05-12 13:33:20 -04:00
nmemmert e60c1fb8f9 Add /study/signup page with auth form, benefits, and start-here style link 2026-05-12 13:28:32 -04:00
nmemmert 29ef1abb74 Add Why Sign Up section to study landing page and auth hint to lesson sidebar 2026-05-12 13:21:54 -04:00
nmemmert a78ae6d6ca Merge feature/colossians-study-pages 2026-05-12 12:47:22 -04:00
nmemmert affd903f40 Make homepage study card admin-editable with NEW tag controls 2026-05-12 12:47:13 -04:00
nmemmert 853e56bd91 Clarify about page image controls in admin 2026-05-12 09:27:29 -04:00
nmemmert becc1b5b27 Enhance admin dashboard and scripture linking 2026-05-12 09:12:55 -04:00
nmemmert 0703a802ff Refine Q&A share feedback and top back-to-site link 2026-05-11 12:06:57 -04:00
nmemmert 7a48130ffb Add social share buttons and per-question OG share stubs 2026-05-11 11:53:01 -04:00
nmemmert f4adeb2844 Refine admin content flow and improve Q&A mobile UX 2026-05-07 14:23:18 -04:00
nmemmert 5f479402a0 Add mobile menu for admin sidebar navigation 2026-05-07 13:13:18 -04:00
nmemmert 7568be368e Add Episode Highlights player with Spotify embed auto-resolution and direct embed URL support 2026-05-07 11:36:07 -04:00
nmemmert 93548b1ac8 Fix CI docker publish install for React 19 peers 2026-05-07 10:32:57 -04:00
nmemmert 90e5c677d8 Improve analytics UX with geo maps and readability updates 2026-05-07 10:29:49 -04:00
nmemmert 3cbb7cc409 Implement comprehensive analytics redesign with bot detection and charts
- Add bot detection logic to differentiate between real traffic and bot traffic
- Detect common bots: search crawlers, social media crawlers, headless browsers, monitoring tools, security scanners
- Expand hit-stats.json to track realHits, botHits, byPathReal, byPathBot, byDayReal, byDayBot, botReasons
- Update analytics API endpoint to return bot/visitor breakdown with 7-day and 30-day comparisons
- Add Chart.js integration with react-chartjs-2 for interactive visualizations
- Create new AnalyticsPanel component with tabbed interface: Overview, Bot Breakdown, Geographic
- Implement graphs showing: 7-day trend (real vs bot), traffic composition (doughnut), bot sources (pie), top pages (bar)
- Display clear KPI cards showing real traffic %, bot traffic %, return visitor rate
- Add deployment & cache status section to analytics dashboard
- Improve data presentation with responsive grid layouts and color-coded metrics
2026-05-07 10:06:32 -04:00
nmemmert 583a9b7b66 Implement email tab redesign, archive support, manual Q&A creation, URL linking, and question admin tools
- Add dedicated Emails tab with inbox/archive views and two-pane layout
- Implement archive/unarchive for contact submissions with persisted state
- Add manual question creation endpoint and admin form (non-contact origin)
- Implement URL auto-linking in Q&A answers with safe rendering
- Add question admin tools: search, filter (All/Pending/Approved/Answered/Unanswered), pagination
- Expand admin panel widths to reduce cramping
- Restore and enhance Asset Manager table layout
- Update email reply template with fixed from-address and HTML support
2026-05-07 09:43:16 -04:00
nmemmert bc3d906e64 Add per-download Amazon button settings for current and archived downloads 2026-05-07 07:49:02 -04:00
nmemmert 9f0aa8bd65 Make welcome email editable in admin and skip admin notice for newsletter signups 2026-05-06 15:45:44 -04:00
nmemmert 272eb607c8 Add personalized newsletter welcome email flow 2026-05-06 15:38:16 -04:00
nmemmert 017a7fca88 Clean up footer with More Platforms menu 2026-05-06 11:38:08 -04:00
nmemmert a2c3e606bd Restore Spotify icon import 2026-05-06 11:12:59 -04:00
nmemmert 71e429f758 Merge seeded platform links on container upgrades 2026-05-06 11:10:55 -04:00
nmemmert fd60c8e796 Fix lint errors in App icons import 2026-05-06 10:57:42 -04:00
nmemmert 42bac71fd6 Add podcast platform badges and Headliner widget 2026-05-06 10:54:38 -04:00
nmemmert 475abff3cf Restore share section with QR code and Share the Show button 2026-05-05 16:52:26 -04:00
nmemmert 8d3755f537 Make 'New here' a secondary button in hero 2026-05-05 16:41:00 -04:00
nmemmert 5088687b58 Update nav: book icon logo, add Q&A link, remove Home link 2026-05-05 16:39:25 -04:00
nmemmert 7c02062663 Make all site content editable from admin; add per-page content blocks 2026-05-05 16:17:23 -04:00
nmemmert 79eb72635e Fix download link styling and raise download description limits 2026-05-04 15:08:27 -04:00
nmemmert 554c248ea7 Fix: copy server/ directory into Docker runtime image 2026-05-04 14:03:47 -04:00
nmemmert 3b91fac6a7 Merge beta: TOTP 2FA, admin asset manager, resource page redesign, rate limiting, and security hardening 2026-05-04 13:56:09 -04:00
nmemmert 1551599305 Beta: TOTP 2FA, admin asset manager, resource page redesign, rate limiting, and security hardening 2026-05-04 13:56:04 -04:00
nmemmert 7f56060d6b Beta: admin assets, resource download forms, and resource page redesign 2026-04-30 15:47:10 -04:00
nmemmert 10bd6848ef Simplify podcast highlight labels on episodes page 2026-04-27 16:39:54 -04:00
nmemmert b26103b7b1 Reorganize admin workflow and persist episode discussion questions 2026-04-27 16:25:26 -04:00
nmemmert 83743cc78b Add robots.txt and sitemap.xml for SEO 2026-04-27 15:09:37 -04:00
nmemmert ee153cf9ef image 2026-04-27 11:26:01 -04:00
nmemmert 88860bc42a Serve images first-party and remove necloud domain refs 2026-04-27 10:58:09 -04:00
nmemmert c5cc28b36b Fix podcast art route for Docker runtime dist path 2026-04-27 10:38:57 -04:00
nmemmert 73b8299f1a Add podcast redirects and route through first-party links 2026-04-27 10:31:10 -04:00
nmemmert b4f1c1dbdb Polish About, forms, and share/contact UI 2026-04-23 17:03:41 -04:00
nmemmert 8a71aecb4e Refine homepage sections and contact card design 2026-04-20 15:38:55 -04:00
nmemmert cc4a4b4d63 Gate Titus download, require full name forms, remove companion UI 2026-04-20 11:29:37 -04:00
nmemmert 0c328057c8 Polish hero UX and add admin brand kit panel 2026-04-16 10:31:35 -04:00
nmemmert 5562db992b Ignore generated runtime data files 2026-04-16 08:37:32 -04:00
nmemmert 287fa72462 Replace chatbot with Q&A and add archived series management 2026-04-16 08:25:04 -04:00
nmemmert 50459b7234 Enhance chatbot conversation engine and add retrieval eval suite 2026-04-14 10:36:22 -04:00
nmemmert 36594d5e16 Add dedicated subscribe page and seed all JSON defaults on startup 2026-04-13 12:06:48 -04:00
nmemmert 0e9d112c44 Fix: Use absolute path for data bind mount instead of relative path 2026-04-13 10:48:29 -04:00
nmemmert 063f5d2a0e Add episode transcripts, expand chatbot Q&A database to 28 entries, and build scripts 2026-04-13 10:29:06 -04:00
nmemmert 6e8b360398 Refine sharing and cookie consent button styles 2026-04-12 19:03:07 -04:00
nmemmert 770d2357ac Add simple auth for admin access 2026-04-12 13:34:11 -04:00
nmemmert f07d8fecfb Add Bible Questions inbox from contact submissions 2026-04-12 13:25:59 -04:00
nmemmert dcfce9dc24 Enhance contact CTA prominence and copy clarity 2026-04-12 13:22:19 -04:00
nmemmert 738cc2a412 Add persistent admin analytics, backup restore workflow, and tabbed admin UI 2026-04-12 13:12:03 -04:00
nmemmert bac22950ba Update about description location to Lynchburg, Va 2026-04-10 11:14:44 -04:00
nmemmert 85af1a29ad Route contact form delivery to hello@versebyversewithnate.us 2026-04-10 10:53:03 -04:00
nmemmert 85e8a298fd Revert: deliver contact form emails to vbvwithnate@outlook.com 2026-04-09 17:10:09 -04:00
nmemmert a252167052 Deliver contact form emails to hello@versebyversewithnate.us 2026-04-09 17:08:56 -04:00
nmemmert 53408ad124 Switch sender to hello@versebyversewithnate.us 2026-04-09 16:39:16 -04:00
nmemmert d5f15486d8 Add explicit contact opt-in before Resend list signup 2026-04-09 16:11:49 -04:00
nmemmert afb80aaf2a Fix Resend contact sync payload and support separate contacts key 2026-04-09 16:05:46 -04:00
nmemmert 3a9d11cf40 Add Resend contact sync for contact form submissions 2026-04-09 15:54:53 -04:00
nmemmert 2653d95605 update 2026-04-09 15:51:17 -04:00
nmemmert 7772bfd6a6 Update contact email sender name 2026-04-09 15:49:16 -04:00
nmemmert 74650a39b3 Polish contact email subject and HTML layout 2026-04-09 15:48:40 -04:00
nmemmert c46d765759 Fix Resend reply-to field for contact emails 2026-04-09 15:47:31 -04:00
nmemmert d95ed9cd8d Switch contact email to Resend API, remove nodemailer/SMTP 2026-04-09 14:40:19 -04:00
nmemmert 4b01d01133 Switch contact form to self-hosted SMTP via nodemailer, remove formsubmit.co 2026-04-09 14:38:28 -04:00
nmemmert e93840c25f Add contact form and thank-you redirect flow 2026-04-09 14:28:36 -04:00
nmemmert 5b401671db Replace Vite lightning bolt favicon with on-brand open book icon 2026-04-09 13:35:52 -04:00
nmemmert f156257573 Merge podcast-verse-by-verse into main 2026-04-09 11:37:02 -04:00
116 changed files with 48594 additions and 1135 deletions
+19
View File
@@ -0,0 +1,19 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "siteforge",
"runtimeExecutable": "node",
"runtimeArgs": ["--env-file=.env", "server.js"],
"port": 4173,
"autoPort": false
},
{
"name": "siteforge-vite",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173,
"autoPort": false
}
]
}
Submodule .claude/worktrees/agent-a3dddb4d7aa52218e added at e17a818deb
-1
View File
@@ -1,5 +1,4 @@
node_modules node_modules
dist
.git .git
.gitignore .gitignore
.vscode .vscode
+28
View File
@@ -0,0 +1,28 @@
# Copy this file to .env and fill in your real values.
# Never commit .env to version control.
# Resend API key for the contact form
# 1. Sign up free at https://resend.com
# 2. Go to API Keys and create a new key
# 3. Paste it below
RESEND_API_KEY=re_your_api_key_here
# Optional: use a separate key for Contacts/Segments if your main key is send-only
RESEND_CONTACTS_API_KEY=
# Email address that receives contact form submissions
RESEND_TO=hello@versebyversewithnate.us
# Optional: automatically add contact form submitters to a Resend Segment
# Create a Segment in Resend and paste its ID here if you want new contacts grouped.
RESEND_SEGMENT_ID=
# "From" address shown on received emails.
# During testing you can leave this as-is (uses Resend's shared domain).
# For production: verify your own domain at resend.com/domains and change this.
RESEND_FROM=Verse by Verse with Nate <hello@versebyversewithnate.us>
# Shared secret for the Cloudflare Email Worker webhook.
# Must match the WEBHOOK_SECRET variable set in the Cloudflare Worker settings.
# Generate with: openssl rand -hex 32
INBOUND_EMAIL_SECRET=
+8 -3
View File
@@ -13,6 +13,8 @@ permissions:
jobs: jobs:
docker: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -24,12 +26,12 @@ jobs:
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: '22'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci --legacy-peer-deps
- name: Run production build - name: Run production build
run: npm run build run: npm run build
@@ -56,5 +58,8 @@ jobs:
with: with:
context: . context: .
push: true push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: |
COMMIT_SHA=${{ github.sha }}
+10
View File
@@ -17,6 +17,16 @@ dist-ssr
# the image default and is preserved here for the Docker build context only. # the image default and is preserved here for the Docker build context only.
# Uncomment the line below if you do NOT want to track live data in git. # Uncomment the line below if you do NOT want to track live data in git.
# data/admin-content.json # data/admin-content.json
data/backups/
data/hit-stats.json
data/visitor-stats.json
data/contact-submissions.json
data/totp-secret.json
data/uploads-meta.json
# Local environment secrets — never commit
.env
.env.*
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
Binary file not shown.
+12 -9
View File
@@ -1,11 +1,9 @@
FROM node:22-alpine AS build # Install prod deps on the native builder platform to avoid QEMU npm crashes
# during cross-platform builds. All deps are pure-JS so this is safe.
FROM --platform=$BUILDPLATFORM node:22-alpine AS deps
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci --omit=dev --legacy-peer-deps
COPY . .
RUN npm run build
FROM node:22-alpine AS runtime FROM node:22-alpine AS runtime
WORKDIR /app WORKDIR /app
@@ -13,11 +11,16 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=4173 ENV PORT=4173
COPY package*.json ./ ARG COMMIT_SHA=unknown
RUN npm ci --omit=dev ENV COMMIT_SHA=${COMMIT_SHA}
COPY --from=build /app/dist ./dist COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./package.json
COPY dist ./dist
COPY server.js ./server.js COPY server.js ./server.js
COPY server/ ./server/
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
# only when no live data exists yet — upgrades never overwrite existing data. # only when no live data exists yet — upgrades never overwrite existing data.
Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

+228 -84
View File
@@ -1,115 +1,259 @@
# Siteforge # Siteforge
This project is a React + TypeScript portfolio app built with Vite. A podcast and ministry website for **Verse by Verse with Nate** — a verse-by-verse Bible teaching podcast. Built with React + TypeScript (Vite) on the frontend and Express.js on the backend, deployed as a Docker container via GitHub Actions.
It is designed to showcase two categories of work: ## Features
- Websites - **Podcast episodes** — episode listing, detail pages, downloadable resources, finished series archive
- Apps - **Study system** — per-user enrollment in structured Bible studies with sections, notes, progress tracking, section quizzes, community posts/comments, and completion certificates
- **Study accounts** — signup/login, email verification, avatar upload, email change with re-verification
- **Two-factor authentication** — TOTP authenticator app + email OTP backup, recovery codes
- **Contact form** — submissions stored server-side, admin reply with templates, inbound email webhook (Resend)
- **Q&A** — public question submission, admin moderation, pinned answers, shareable question links
- **Email integration** — welcome emails, admin notifications, admin replies, study reminder emails (via Resend)
- **Admin panel** — content management with draft/publish workflow, episode scripts, podcast checklist, QR code management, subscriber management, asset uploads
- **Analytics** — page views, audio plays, download tracking, geo-based visitor stats (country/state/city), consent-based cookie opt-in
- **Backup system** — automatic JSON snapshots with restore, full `tar.gz` data export/import for server migration
- **Version display** — app version and git commit shown in admin sidebar; `/api/version` endpoint
The app now supports: ---
- Dynamic cards generated from a project data source ## Tech Stack
- Built vs Hosted filters
- A dedicated About page route for each project
- An admin editor route to update cards and About pages
- A per-project README summary block on each project page
- Embedded Spotify podcast player on the home section
## Run locally | Layer | Technology |
|---|---|
| Frontend | React 19, TypeScript, Vite, React Router v7 |
| Backend | Express 5, Node.js 22 |
| Email | Resend |
| TOTP | otplib |
| QR Codes | qrcode |
| Rich text | TipTap |
| Charts | Chart.js + react-chartjs-2, react-simple-maps |
| Documents | docx, mammoth |
| Container | Docker (GHCR), GitHub Actions CI |
---
## Project Structure
```
siteforge/
├── src/ # React frontend
│ ├── App.tsx # Root + all client routes
│ ├── App.css
│ └── ... # Page and component files
├── server/ # Express backend
│ ├── config.js # Constants, env vars, APP_VERSION, GIT_COMMIT
│ ├── data.js # In-memory state + JSON persistence
│ ├── helpers.js # Shared utilities (IP, rate limiting, etc.)
│ ├── study-helpers.js # Study email scheduling, cert generation
│ └── routes/ # Route handlers (one file per domain)
├── data/ # Seed data (JSON); live data at /app/data in container
├── server.js # Entry point — mounts all routers
├── Dockerfile
├── entrypoint.sh # Seeds /app/data on first run, never overwrites live data
├── docker-compose.yml
└── .github/workflows/docker-publish.yml
```
---
## Environment Variables
| Variable | Required | Description |
|---|---|---|
| `SESSION_SECRET` | Yes | Secret for Express session signing |
| `ADMIN_PASSWORD_HASH` | Yes | bcrypt hash of the admin password |
| `RESEND_API_KEY` | Yes | Resend API key for sending email |
| `RESEND_FROM` | Yes | Sender address (e.g. `Nate <nate@example.com>`) |
| `RESEND_TO` | Yes | Address that receives contact form notifications |
| `RESEND_REPLY_TO` | No | Reply-to address on outbound emails |
| `RESEND_WEBHOOK_TOKEN` | No | Validates inbound Resend webhook requests |
| `INBOUND_EMAIL_SECRET` | No | Validates inbound email route requests |
| `RESEND_CONTACTS_API_KEY` | No | Resend Contacts API key for subscriber sync |
| `RESEND_SEGMENT_ID` | No | Resend segment ID for subscriber list |
| `COMMIT_SHA` | No | Git SHA baked in at build time by CI (shown in admin UI) |
| `TRUST_PROXY_HOPS` | No | Number of proxy hops to trust (default `1`) — set correctly for accurate client IPs behind a reverse proxy |
| `ALLOW_INSECURE_COOKIES` | No | Set to `true` to allow session cookies over HTTP (for local/non-HTTPS access) |
| `TITUS_STUDY_FILE` | No | Path to the Titus study PDF (defaults to bundled file) |
| `TITUS_STUDY_DOWNLOAD_NAME` | No | Filename shown to users downloading the Titus PDF |
| `PORT` | No | HTTP port (default `4173`) |
| `CACHE_PURGE_WEBHOOK_URL` | No | Webhook URL for one-click cache purge from Admin Operations |
| `DEPLOY_WEBHOOK_URL` | No | Webhook URL for one-click deploy trigger from Admin Operations |
---
## Frontend Routes
| Path | Page |
|---|---|
| `/` | Landing page |
| `/start-here` | Start here / intro page |
| `/episodes` | Episode listing |
| `/episodes/:id` | Episode detail |
| `/finished` | Finished series listing |
| `/finished/:id` | Finished series detail |
| `/resources` | Resources listing |
| `/downloads/:id` | Download detail |
| `/about` | About page |
| `/contact` | Contact form |
| `/questions` | Q&A listing |
| `/subscribe` | Email subscribe |
| `/subscribe/thanks` | Subscribe confirmation |
| `/study` | Study landing page |
| `/study/signup` | Study account signup |
| `/study/account` | Study account dashboard |
| `/study/:studySlug` | Study index |
| `/study/:studySlug/community` | Study community posts |
| `/study/:studySlug/notes` | Study notes |
| `/study/:studySlug/:sectionId` | Study section |
| `/study/:studySlug/:sectionId/quiz` | Section quiz |
| `/certificate/:token` | Public completion certificate |
| `/privacy` | Privacy policy |
| `/thanks` | Contact thank-you |
| `/admin` | Admin panel (requires password + TOTP) |
| `/preview` | Content draft preview |
---
## Key API Endpoints
| Endpoint | Description |
|---|---|
| `GET /api/version` | Returns `{ version, commit }` |
| `GET /api/content` | Public site content (CMS data) |
| `POST /api/contact` | Submit contact form |
| `POST /api/analytics/pageview` | Record page view |
| `POST /api/analytics/play` | Record audio play |
| `GET /api/episode-audio` | Episode audio proxy |
| `GET /api/admin-auth/status` | Admin session status + version info |
| `POST /api/admin-auth/login` | Admin login |
| `POST /api/admin-auth/totp-verify` | Complete TOTP 2FA |
| `POST /api/admin-auth/totp-setup-init` | Begin TOTP enrollment |
| `POST /api/admin-auth/totp-setup-confirm` | Confirm TOTP enrollment |
| `GET /api/admin-stats` | Hit/visitor analytics |
| `GET /api/admin-stats/backup` | Trigger stats snapshot |
| `GET /api/admin-backup/export` | Download full `tar.gz` data backup |
| `POST /api/admin-backup/import` | Restore from `tar.gz` backup |
| `GET /api/admin/study-users` | List study accounts |
| `GET /api/admin-study-certificates` | Study completion certificates |
| `GET /api/admin-contact-submissions` | Contact form submissions |
| `POST /api/admin-contact-submissions/:id/reply` | Reply to a submission |
| `GET /api/admin/qr-codes` | QR code management |
| `GET /api/admin-episode-scripts/:episodeNumber` | Episode script |
| `GET /api/admin-podcast-checklist` | Podcast checklist state |
| `GET /api/admin-subscribers` | Subscriber list |
---
## Development
```bash ```bash
# Install dependencies
npm install npm install
# Start frontend dev server only (Vite, http://localhost:5173)
npm run dev
# Start backend API server only (reads .env, http://localhost:4173)
npm run api
# Start both concurrently
npm run dev:full npm run dev:full
```
`dev:full` starts both: # Production build
- Vite frontend (`http://localhost:5173`)
- Admin content API (`http://localhost:4173`)
## Build for production
```bash
npm run build npm run build
# Start production server (reads .env)
npm start
``` ```
## Run in container Create a `.env` file at the project root with the required environment variables before running locally. The server uses `--env-file=.env`.
Pull image directly: ---
```bash ## Docker / Deployment
docker pull ghcr.io/nmemmert/siteforge:latest
docker run -d --name siteforge-app -p 4173:4173 -v ./data:/app/data ghcr.io/nmemmert/siteforge:latest Images are built and pushed to `ghcr.io/nmemmert/siteforge` automatically on every push to `main` or when a `v*` tag is pushed. The git commit SHA is baked into the image as `COMMIT_SHA` at build time and displayed in the admin sidebar.
### First run
On first boot, `entrypoint.sh` copies seed data from the bundled `data-seed/` into `/app/data/` if that directory is empty. Subsequent restarts skip this step, so live data is never overwritten by an upgrade.
### docker-compose.yml
A `docker-compose.yml` is included at the project root. Mount `/app/data` to a persistent volume or host path and supply all required environment variables.
```yaml
volumes:
- /path/to/your/data:/app/data
``` ```
Run with Docker Compose: ### Useful commands
```bash ```bash
# Pull latest image and restart
docker compose pull docker compose pull
docker compose up -d docker compose up -d
```
The app will be available at `http://localhost:4173`. # View logs
Persistent admin saves:
- Admin updates are written to `data/admin-content.json`.
- `docker-compose.yml` mounts `./data` into the container at `/app/data`.
- This keeps your edits after container restarts/rebuilds.
## Where to edit content
- Project domain data and ownership: `src/data/projects.ts`
- Portfolio rendering logic: `src/App.tsx`
- Portfolio UI styles: `src/App.css`
- Global theme variables: `src/index.css`
## Routes
- Portfolio home: `/`
- Project details: `/projects/:slug`
- Admin editor: `/admin`
## Admin editing
- Open `/admin` to edit project card and featured-page content.
- Use **Global Settings** to update theme, home headers, and quick tips.
- Use **Project Settings** to update project-specific fields and README URL.
- Use **Create new entry** to add a new project directly from Admin.
- Enter a URL and click **Scan URL metadata** to auto-fill title, summary, domain, category, and icon when available.
- Click **Save project** to apply updates instantly.
- Saved edits are written to `data/admin-content.json` through the API server.
- Use the **Theme** dropdown to switch between Sandstone, Ocean, Midnight, Forest, and Sunset.
- Use **Remove project** to delete the selected project from your local Admin data.
- Use **Reset project** or **Reset all** to restore defaults from `src/data/projects.ts`.
Scan notes:
- URL scanning is best-effort and depends on site accessibility.
- Some sites block direct scanning; Admin falls back to a proxy scan path when possible.
- Always review scanned fields before saving.
Deployment note:
- To keep Admin saves working on the internet, deploy with the Node API (`server.js`) and writable server storage for `data/admin-content.json`.
Useful container commands:
```bash
docker compose logs -f docker compose logs -f
# Stop
docker compose down docker compose down
``` ```
Update to latest image: ---
```bash ## Persistent Data
docker compose pull
docker compose up -d
```
## Notes All server-managed data lives under `/app/data` (or `./data` locally):
- The current project cards are sample placeholders. | Path | Contents |
- Most metadata was scanned from live pages. |---|---|
- Skywatch currently uses a manual fallback description due to a `401` response during automated scan. | `admin-content.json` | Published CMS content |
| `admin-content-draft.json` | Draft content (unpublished) |
| `hit-stats.json` | Page hit analytics |
| `visitor-stats.json` | Detailed visitor analytics |
| `contact-submissions.json` | Contact form submissions |
| `study-users.json` | Study account records |
| `study-reminders.json` | Scheduled study reminder state |
| `qr-codes.json` | QR code registry |
| `episode-scripts/` | Per-episode script files |
| `backups/` | Automatic JSON snapshot backups |
| `uploads/` | Admin-uploaded assets (served at `/uploads/*`) |
---
## Admin Access
1. Navigate to `/admin`
2. Enter the admin password (hashed value in `ADMIN_PASSWORD_HASH`)
3. Complete TOTP 2FA, or use an email OTP if no TOTP app is enrolled
4. On first use, enroll a TOTP authenticator app from Admin → Settings
Recovery codes are generated during TOTP setup and can be regenerated from Admin → Settings.
### Admin capabilities
- **Content** — edit and publish site content with draft/preview workflow
- **Episode scripts** — write and manage per-episode scripts; export to `.docx`
- **Podcast checklist** — track per-episode production steps
- **QR codes** — create, manage, and track scan counts for QR codes
- **Questions** — moderate submitted Q&A, pin answers, reply
- **Contact** — view submissions, reply with templates, check email health
- **Study users** — view enrollments, progress, certificates, manually enroll/unenroll
- **Study comments** — moderate community post comments
- **Subscribers** — view and export subscriber list
- **Assets** — upload and delete hosted images
- **Stats** — page hits, visitor geo analytics, download stats; export CSV
- **Backup** — snapshot now, restore from snapshot, export/import full `tar.gz`
- **Operations** — purge cache, trigger deploy, manage SEO/sitemap/redirects
---
## Versioning
The app version is read from `package.json` at startup (`APP_VERSION`). The git commit is baked in via `COMMIT_SHA` at Docker build time (set automatically by CI). Both are visible in the admin sidebar and at `GET /api/version`.
Bump `package.json` before every commit pushed to `main`: patch for fixes, minor for features, major for breaking changes.
+208
View File
@@ -0,0 +1,208 @@
/**
* Cloudflare Email Worker — versebyversewithnate.us
*
* Receives inbound emails to hello@ and nate@, forwards to Gmail,
* and POSTs a parsed copy to the site admin inbox.
*
* CLOUDFLARE SETUP:
* 1. Go to Email > Email Routing > Routes in your Cloudflare dashboard
* 2. Add two "Custom address" rules:
* hello@versebyversewithnate.us → Send to Worker → this worker
* nate@versebyversewithnate.us → Send to Worker → this worker
* 3. Make sure nmemmert@gmail.com is listed as a verified destination address
* (Email Routing > Destination addresses)
*
* WORKER ENVIRONMENT VARIABLES (Workers & Pages > this worker > Settings > Variables):
* GMAIL_FORWARD_ADDRESS = nmemmert@gmail.com
* WEBHOOK_URL = https://versebyversewithnate.us/api/inbound-email
* WEBHOOK_SECRET = <a long random string — must match INBOUND_EMAIL_SECRET on the server>
*
* SERVER ENVIRONMENT VARIABLE (set in your server host / .env):
* INBOUND_EMAIL_SECRET = <same long random string as WEBHOOK_SECRET above>
*/
export default {
async email(message, env, ctx) {
const forwardPromise = message.forward(env.GMAIL_FORWARD_ADDRESS)
const rawEmail = await streamToText(message.raw)
const parsed = parseEmail(rawEmail, message)
const webhookPromise = fetch(env.WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Secret': env.WEBHOOK_SECRET,
},
body: JSON.stringify(parsed),
}).catch(err => console.error('[email-worker] webhook failed:', err.message))
await Promise.all([forwardPromise, webhookPromise])
},
}
async function streamToText(stream) {
const reader = stream.getReader()
const chunks = []
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
}
const bytes = new Uint8Array(chunks.reduce((acc, c) => acc + c.length, 0))
let offset = 0
for (const chunk of chunks) {
bytes.set(chunk, offset)
offset += chunk.length
}
return new TextDecoder().decode(bytes)
}
function parseEmail(raw, message) {
const { headers, body } = splitHeadersAndBody(raw)
const subject = decodeHeaderValue(headers['subject'] ?? '(no subject)')
const from = message.from ?? headers['from'] ?? ''
const to = message.to ?? headers['to'] ?? ''
const date = headers['date'] ?? new Date().toISOString()
const messageId = headers['message-id'] ?? ''
const { plainText, htmlBody } = extractBodies(headers['content-type'] ?? '', body)
return {
from,
to,
subject,
body: plainText,
htmlBody: htmlBody || null,
date,
messageId,
source: 'inbound-email',
}
}
// Splits an RFC 5322 message (or MIME part) into its unfolded header map and raw body string.
function splitHeadersAndBody(raw) {
const match = /\r?\n\r?\n/.exec(raw)
const headerBlock = match ? raw.slice(0, match.index) : raw
const body = match ? raw.slice(match.index + match[0].length) : ''
// RFC 2822 header folding: a line starting with SP/TAB continues the previous header line.
const unfolded = headerBlock.replace(/\r?\n[ \t]+/g, ' ')
const headers = {}
for (const line of unfolded.split(/\r?\n/)) {
const colon = line.indexOf(':')
if (colon <= 0) continue
const key = line.slice(0, colon).trim().toLowerCase()
const val = line.slice(colon + 1).trim()
if (!headers[key]) headers[key] = val
}
return { headers, body }
}
// Recursively walks a (possibly multipart) MIME body and returns the first text/plain and text/html parts found.
function extractBodies(topContentType, topBody) {
let plainText = null
let htmlBody = null
function visit(contentType, body, transferEncoding) {
const type = (contentType.split(';')[0] || 'text/plain').trim().toLowerCase()
if (type.startsWith('multipart/')) {
const boundary = getBoundary(contentType)
if (!boundary) return
for (const part of splitOnBoundary(body, boundary)) {
const { headers, body: partBody } = splitHeadersAndBody(part)
visit(headers['content-type'] ?? 'text/plain', partBody, headers['content-transfer-encoding'] ?? '')
}
return
}
const decoded = decodeBody(body, transferEncoding)
if (type === 'text/html' && htmlBody === null) htmlBody = decoded.trim()
if (type === 'text/plain' && plainText === null) plainText = decoded.trim()
}
visit(topContentType || 'text/plain', topBody, '')
if (plainText === null && htmlBody !== null) {
plainText = htmlBody.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/\s{3,}/g, '\n\n').trim()
}
if (plainText === null) plainText = ''
return { plainText, htmlBody }
}
function getBoundary(contentType) {
const match = /boundary\s*=\s*"([^"]+)"|boundary\s*=\s*([^;\s]+)/i.exec(contentType)
if (!match) return null
return match[1] ?? match[2]
}
// Splits a multipart body on its boundary markers, ignoring the preamble/epilogue.
function splitOnBoundary(body, boundary) {
const escaped = boundary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const re = new RegExp(`(?:^|\\r?\\n)--${escaped}(--)?(?:\\r?\\n|$)`, 'g')
const parts = []
let lastIndex = 0
let started = false
let match
while ((match = re.exec(body)) !== null) {
if (started) parts.push(body.slice(lastIndex, match.index))
started = true
lastIndex = match.index + match[0].length
if (match[1]) break // final boundary: "--boundary--"
}
return parts
}
function decodeBody(text, transferEncoding) {
const enc = (transferEncoding || '').trim().toLowerCase()
if (enc === 'base64') {
try {
const binary = atob(text.replace(/\s+/g, ''))
return new TextDecoder('utf-8').decode(Uint8Array.from(binary, c => c.charCodeAt(0)))
} catch {
return ''
}
}
if (enc === 'quoted-printable') {
return decodeEmailBody(text)
}
return text
}
function decodeEmailBody(text) {
// Handle quoted-printable encoding (=XX hex sequences and soft line breaks).
// Decode into raw bytes first, then run through TextDecoder so multi-byte
// UTF-8 sequences split across multiple =XX escapes recombine correctly.
const unfolded = text.replace(/=\r?\n/g, '')
const bytes = []
for (let i = 0; i < unfolded.length; i++) {
if (unfolded[i] === '=' && /^[0-9A-Fa-f]{2}$/.test(unfolded.slice(i + 1, i + 3))) {
bytes.push(parseInt(unfolded.slice(i + 1, i + 3), 16))
i += 2
} else {
bytes.push(unfolded.charCodeAt(i))
}
}
return new TextDecoder('utf-8').decode(Uint8Array.from(bytes))
}
function decodeHeaderValue(value) {
// Handle encoded words: =?UTF-8?B?...?= or =?UTF-8?Q?...?=
return value.replace(/=\?([^?]+)\?([BQ])\?([^?]*)\?=/gi, (_, charset, encoding, encoded) => {
try {
if (encoding.toUpperCase() === 'B') {
const binary = atob(encoded)
return new TextDecoder(charset).decode(Uint8Array.from(binary, c => c.charCodeAt(0)))
}
if (encoding.toUpperCase() === 'Q') {
return encoded.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
}
} catch {
return encoded
}
return encoded
})
}
File diff suppressed because it is too large Load Diff
+1154 -76
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"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"
}
+36
View File
@@ -0,0 +1,36 @@
{
"scrollDepth": {
"/episodes": {
"25": 4,
"50": 4,
"75": 2,
"90": 0
}
},
"timeOnPage": {
"/": {
"totalSeconds": 51,
"count": 4
},
"/episodes": {
"totalSeconds": 318967,
"count": 305
},
"/episodes/movn6tk3": {
"totalSeconds": 445,
"count": 3
}
},
"outboundClicks": {},
"linkClicks": {},
"utmSources": {},
"searchQueries": {},
"notFound": {},
"audioEvents": {
"Living Between Two Appearings": {
"pauses": 0,
"completions": 0,
"totalListenSeconds": 43
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"Living Between Two Appearings": {
"total": 1,
"byDay": {
"2026-07-22": 1
}
}
}
+681
View File
@@ -0,0 +1,681 @@
{
"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"
}
+102
View File
@@ -0,0 +1,102 @@
{
"questions": [
{
"id": "cb_001",
"firstName": "Nate",
"question": "What Bible translation do you use?",
"answer": "In the podcast, I primarily teach from the BSB (Berean Standard Bible). I may compare other translations at times, but BSB is my main teaching text in the Verse by Verse episodes.",
"isApproved": true,
"topic": "Bible Study",
"answeredAt": "2026-05-07T13:36:16.859Z"
},
{
"id": "cb_002",
"firstName": "Nate",
"question": "How do you approach a difficult or confusing Bible passage?",
"answer": "I always start by reading the surrounding context — a confusing verse often makes perfect sense once you zoom out a chapter or two. Then I look at who the author was, who they were writing to, and what was happening historically. A good study Bible or a commentary like Matthew Henry's can be a huge help. Most importantly, pray for understanding before you dive in. The Holy Spirit is your best guide.",
"isApproved": true,
"topic": "Bible Study"
},
{
"id": "cb_003",
"firstName": "Nate",
"question": "How do I stay consistent with daily Bible reading?",
"answer": "Start small and protect that time like an appointment. Even 10 minutes in the morning before checking your phone can be transformative over a year. A reading plan helps a lot — there are great ones on apps like YouVersion that break the whole Bible into daily chunks so you never have to decide what to read. And give yourself grace on the days you miss; guilt is not a good motivator. Just pick back up where you left off.",
"isApproved": true,
"topic": "Bible Study"
},
{
"id": "cb_007",
"firstName": "Nate",
"question": "How do I submit a question to Nate?",
"answer": "You can submit a question using the contact form at the bottom of this page. Select 'Bible Question' as the message type. Nate reads every question personally and answers the best ones on the site's Q&A section or in a future episode. There's no guarantee every question gets a public response, but all are read and appreciated!",
"isApproved": true,
"topic": "Podcast"
},
{
"id": "cb_faith_family_001",
"firstName": "Nate",
"question": "How can I share my faith with skeptical family?",
"answer": "Lead with relationship, not argument. Skeptical family members usually don't need more information — they need to see the faith lived out genuinely. Consistency, love, and patience over time speaks louder than any debate win.\n\nPray specifically and persistently. This is the most underrated strategy. God moves hearts in ways arguments never can.",
"isApproved": true,
"topic": "Faith & Life"
},
{
"id": "cb_blessed_hope_001",
"firstName": "Nate",
"question": "What is the blessed hope?",
"answer": "The blessed hope is a phrase from Titus 2:13 — \"as we await the blessed hope and glorious appearance of our great God and Savior Jesus Christ.\" In Episode 9, Nate breaks down the Greek: makaria elpis. Makaria is the same word used in the Beatitudes — a divinely favored condition. And elpis in the New Testament is not wishful thinking. Biblical hope is a confident expectation of something certain. Not a wish. An anchor.\n\nThe blessed hope is simply this: Jesus is coming back. Not maybe. He is coming back. And the person who really believes that is in a blessed condition even now, living toward a certainty that changes how everything feels today.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_who_is_titus_001",
"firstName": "Nate",
"question": "Who was Titus?",
"answer": "Titus was a Gentile — not Jewish — who came to faith through Paul's ministry. Paul calls him \"my true child in our common faith\" (Titus 1:4). He was one of Paul's closest and most trusted co-workers. In 2 Corinthians 7, Paul describes being comforted by Titus arriving during a difficult season. In chapter 8, Titus helped organize a relief offering for believers in Jerusalem. In Galatians 2, he appears as proof that the gospel is for everyone.\n\nPaul left Titus on the island of Crete with a difficult assignment: appoint elders, correct false teaching, and help a young church find its footing in a challenging culture. The letter of Titus is essentially his briefing.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_deny_works_001",
"firstName": "Nate",
"question": "What does it mean to deny God by your works?",
"answer": "Titus 1:16 says: \"They profess to know God, but by their actions they deny Him. They are detestable, disobedient, and unfit for any good deed.\"\n\nIn Episode 6, Nate explains this using three precise words Paul chose. Detestable — morally repulsive before God, regardless of religious appearance. Disobedient — not persuadable, resistant to truth even when confronted with it. Unfit — a Greek metalworking term for metal tested and found counterfeit. Religious on the surface, but no real substance underneath.\n\nThe warning cuts both ways: a person can know the right words, attend the right services, hold the right opinions — and still live in a way that functionally says God doesn't matter. Theology and life are inseparable. What you actually believe will show itself in how you actually live.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_grace_trains_001",
"firstName": "Nate",
"question": "What does grace actually train us to do?",
"answer": "Titus 2:11-12 says grace \"instructs us to renounce ungodliness and worldly passions, and to live sensible, upright, and godly lives.\"\n\nIn Episode 8, Nate points out that the Greek word for instructs is paideuō — the education of a child. Not classroom instruction but formation. Grace is the subject doing the teaching.\n\nGrace teaches two things: what to say no to (ungodliness and worldly passions) and what to say yes to — sensible (an ordered mind), upright (right in relationship with others), and godly (God genuinely at the center). Inward, outward, and upward.\n\nThe key insight: the pathway to saying no to sin is going deeper into grace, not trying harder. Grace properly understood reorients your desires — it doesn't just change the rules, it changes what you want. Grace is not a hall pass. It's a school you attend every day.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_leadership_titus1_001",
"firstName": "Nate",
"question": "What does Titus 1 teach about church leadership?",
"answer": "Paul's first instruction in Titus was to appoint elders — because a church without mature, qualified leadership is vulnerable. From Episode 4 (Titus 1:5-9):\n\nThe elder's home is exhibit A. Before looking at preaching or public presence, look at the household. If a man cannot lead his own family well, there's no reason to believe he'll lead God's family well. Integrity can't be compartmentalized.\n\nPaul gives five things an elder must NOT be: not self-absorbed, not quick-tempered, not given to drunkenness, not violent, not greedy. And six things he must BE: hospitable, a lover of good, self-controlled, upright, holy, and disciplined in sound doctrine.\n\nThis is not a job description for a CEO. Nate frames it as a picture of Christlike character — authority that flows from below, from a posture of submission and service, not dominance.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_titus347_summary_001",
"firstName": "Nate",
"question": "Give me a summary of Titus 3:4-7",
"answer": "Titus 3:4-7 is what Nate calls 'the gospel in one paragraph' — one of the most concentrated summaries of salvation anywhere in Paul's letters. Episode 12 breaks it down:\n\nAll three persons of the Trinity are present: God the Father is the initiating motivation — He is kind and full of love toward humanity. The Holy Spirit is the agent of new birth and ongoing renewal. Jesus Christ is the one through whom the Spirit is poured out, and verse 7 calls him 'our Savior.'\n\nThe hinge of the passage is verse 5: He saved us not by the righteous deeds we had done, but according to His mercy. Not partially. Not mostly. The basis is His mercy, not our merit — which means salvation rests on something that cannot change.\n\nVerse 7 gives us two results: justified (declared righteous in God's courtroom) and heirs (members of the family with a guaranteed inheritance). You are not just legally acquitted — you belong.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_christians_politics_001",
"firstName": "Nate",
"question": "How should Christians engage with politics and public life?",
"answer": "Titus 3:1-2 is Paul's answer, covered in Episode 10. He tells Titus to remind believers to submit to rulers and authorities, be obedient, be ready for every good work, malign no one, and be peaceable, gentle, and considerate to everyone.\n\nNate's key insight: Paul is not saying agree with every government decision. He's saying your engagement with public life should be marked by something recognizable as different. Submission, readiness to do good, and a peaceable presence are not just civic virtues — they are missionary strategies. The way you navigate public life either opens doors for the gospel or closes them.\n\nThe word for peaceable in Greek is amachous — literally non-combative. Paul doesn't say malign no one except your political opponents. He says no one. This is not weakness. It is the confidence of people who know how the story ends and don't need to win every argument to prove it.",
"isApproved": true,
"topic": "Faith & Life"
}
],
"updatedAt": "2026-05-07T13:36:16.859Z"
}
+102
View File
@@ -0,0 +1,102 @@
{
"questions": [
{
"id": "cb_001",
"firstName": "Nate",
"question": "What Bible translation do you use?",
"answer": "In the podcast, I primarily teach from the BSB (Berean Standard Bible). I may compare other translations at times, but BSB is my main teaching text in the Verse by Verse episodes.",
"isApproved": true,
"topic": "Bible Study",
"answeredAt": "2026-05-07T13:36:16.859Z"
},
{
"id": "cb_002",
"firstName": "Nate",
"question": "How do you approach a difficult or confusing Bible passage?",
"answer": "I always start by reading the surrounding context — a confusing verse often makes perfect sense once you zoom out a chapter or two. Then I look at who the author was, who they were writing to, and what was happening historically. A good study Bible or a commentary like Matthew Henry's can be a huge help. Most importantly, pray for understanding before you dive in. The Holy Spirit is your best guide.",
"isApproved": true,
"topic": "Bible Study"
},
{
"id": "cb_003",
"firstName": "Nate",
"question": "How do I stay consistent with daily Bible reading?",
"answer": "Start small and protect that time like an appointment. Even 10 minutes in the morning before checking your phone can be transformative over a year. A reading plan helps a lot — there are great ones on apps like YouVersion that break the whole Bible into daily chunks so you never have to decide what to read. And give yourself grace on the days you miss; guilt is not a good motivator. Just pick back up where you left off.",
"isApproved": true,
"topic": "Bible Study"
},
{
"id": "cb_007",
"firstName": "Nate",
"question": "How do I submit a question to Nate?",
"answer": "You can submit a question using the contact form at the bottom of this page. Select 'Bible Question' as the message type. Nate reads every question personally and answers the best ones on the site's Q&A section or in a future episode. There's no guarantee every question gets a public response, but all are read and appreciated!",
"isApproved": true,
"topic": "Podcast"
},
{
"id": "cb_faith_family_001",
"firstName": "Nate",
"question": "How can I share my faith with skeptical family?",
"answer": "Lead with relationship, not argument. Skeptical family members usually don't need more information — they need to see the faith lived out genuinely. Consistency, love, and patience over time speaks louder than any debate win.\n\nPray specifically and persistently. This is the most underrated strategy. God moves hearts in ways arguments never can.",
"isApproved": true,
"topic": "Faith & Life"
},
{
"id": "cb_blessed_hope_001",
"firstName": "Nate",
"question": "What is the blessed hope?",
"answer": "The blessed hope is a phrase from Titus 2:13 — \"as we await the blessed hope and glorious appearance of our great God and Savior Jesus Christ.\" In Episode 9, Nate breaks down the Greek: makaria elpis. Makaria is the same word used in the Beatitudes — a divinely favored condition. And elpis in the New Testament is not wishful thinking. Biblical hope is a confident expectation of something certain. Not a wish. An anchor.\n\nThe blessed hope is simply this: Jesus is coming back. Not maybe. He is coming back. And the person who really believes that is in a blessed condition even now, living toward a certainty that changes how everything feels today.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_who_is_titus_001",
"firstName": "Nate",
"question": "Who was Titus?",
"answer": "Titus was a Gentile — not Jewish — who came to faith through Paul's ministry. Paul calls him \"my true child in our common faith\" (Titus 1:4). He was one of Paul's closest and most trusted co-workers. In 2 Corinthians 7, Paul describes being comforted by Titus arriving during a difficult season. In chapter 8, Titus helped organize a relief offering for believers in Jerusalem. In Galatians 2, he appears as proof that the gospel is for everyone.\n\nPaul left Titus on the island of Crete with a difficult assignment: appoint elders, correct false teaching, and help a young church find its footing in a challenging culture. The letter of Titus is essentially his briefing.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_deny_works_001",
"firstName": "Nate",
"question": "What does it mean to deny God by your works?",
"answer": "Titus 1:16 says: \"They profess to know God, but by their actions they deny Him. They are detestable, disobedient, and unfit for any good deed.\"\n\nIn Episode 6, Nate explains this using three precise words Paul chose. Detestable — morally repulsive before God, regardless of religious appearance. Disobedient — not persuadable, resistant to truth even when confronted with it. Unfit — a Greek metalworking term for metal tested and found counterfeit. Religious on the surface, but no real substance underneath.\n\nThe warning cuts both ways: a person can know the right words, attend the right services, hold the right opinions — and still live in a way that functionally says God doesn't matter. Theology and life are inseparable. What you actually believe will show itself in how you actually live.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_grace_trains_001",
"firstName": "Nate",
"question": "What does grace actually train us to do?",
"answer": "Titus 2:11-12 says grace \"instructs us to renounce ungodliness and worldly passions, and to live sensible, upright, and godly lives.\"\n\nIn Episode 8, Nate points out that the Greek word for instructs is paideuō — the education of a child. Not classroom instruction but formation. Grace is the subject doing the teaching.\n\nGrace teaches two things: what to say no to (ungodliness and worldly passions) and what to say yes to — sensible (an ordered mind), upright (right in relationship with others), and godly (God genuinely at the center). Inward, outward, and upward.\n\nThe key insight: the pathway to saying no to sin is going deeper into grace, not trying harder. Grace properly understood reorients your desires — it doesn't just change the rules, it changes what you want. Grace is not a hall pass. It's a school you attend every day.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_leadership_titus1_001",
"firstName": "Nate",
"question": "What does Titus 1 teach about church leadership?",
"answer": "Paul's first instruction in Titus was to appoint elders — because a church without mature, qualified leadership is vulnerable. From Episode 4 (Titus 1:5-9):\n\nThe elder's home is exhibit A. Before looking at preaching or public presence, look at the household. If a man cannot lead his own family well, there's no reason to believe he'll lead God's family well. Integrity can't be compartmentalized.\n\nPaul gives five things an elder must NOT be: not self-absorbed, not quick-tempered, not given to drunkenness, not violent, not greedy. And six things he must BE: hospitable, a lover of good, self-controlled, upright, holy, and disciplined in sound doctrine.\n\nThis is not a job description for a CEO. Nate frames it as a picture of Christlike character — authority that flows from below, from a posture of submission and service, not dominance.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_titus347_summary_001",
"firstName": "Nate",
"question": "Give me a summary of Titus 3:4-7",
"answer": "Titus 3:4-7 is what Nate calls 'the gospel in one paragraph' — one of the most concentrated summaries of salvation anywhere in Paul's letters. Episode 12 breaks it down:\n\nAll three persons of the Trinity are present: God the Father is the initiating motivation — He is kind and full of love toward humanity. The Holy Spirit is the agent of new birth and ongoing renewal. Jesus Christ is the one through whom the Spirit is poured out, and verse 7 calls him 'our Savior.'\n\nThe hinge of the passage is verse 5: He saved us not by the righteous deeds we had done, but according to His mercy. Not partially. Not mostly. The basis is His mercy, not our merit — which means salvation rests on something that cannot change.\n\nVerse 7 gives us two results: justified (declared righteous in God's courtroom) and heirs (members of the family with a guaranteed inheritance). You are not just legally acquitted — you belong.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_christians_politics_001",
"firstName": "Nate",
"question": "How should Christians engage with politics and public life?",
"answer": "Titus 3:1-2 is Paul's answer, covered in Episode 10. He tells Titus to remind believers to submit to rulers and authorities, be obedient, be ready for every good work, malign no one, and be peaceable, gentle, and considerate to everyone.\n\nNate's key insight: Paul is not saying agree with every government decision. He's saying your engagement with public life should be marked by something recognizable as different. Submission, readiness to do good, and a peaceable presence are not just civic virtues — they are missionary strategies. The way you navigate public life either opens doors for the gospel or closes them.\n\nThe word for peaceable in Greek is amachous — literally non-combative. Paul doesn't say malign no one except your political opponents. He says no one. This is not weakness. It is the confidence of people who know how the story ends and don't need to win every argument to prove it.",
"isApproved": true,
"topic": "Faith & Life"
}
],
"updatedAt": "2026-06-03T14:17:48.087Z"
}
+50
View File
@@ -0,0 +1,50 @@
{
"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"
}
+8
View File
@@ -0,0 +1,8 @@
{
"notesByUser": {
"e43e7217-be69-49c6-a65d-edd0e7745884": {
"1-1-2": "Test"
}
},
"updatedAt": "2026-05-12T15:51:54.402Z"
}
@@ -0,0 +1,3 @@
{
"colossians--1-1-2": "test....teset"
}
@@ -0,0 +1,3 @@
{
"1-1-2": "Test"
}
@@ -0,0 +1,17 @@
{
"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
@@ -0,0 +1,4 @@
{
"users": {},
"updatedAt": "2026-08-10T14:22:31.192Z"
}
+73
View File
@@ -0,0 +1,73 @@
{
"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: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

+4 -1
View File
@@ -7,6 +7,9 @@ services:
- "4173:4173" - "4173:4173"
environment: environment:
- PORT=4173 - PORT=4173
- SITEFORGE_DATA_DIR=/app/data
- ADMIN_PASSWORD=`generate a random password and set it here`
- RESEND_API_KEY=`set your Resend API key here`
volumes: volumes:
- ./data:/app/data - /media/ZimaOS-HD/AppData/siteforge/data:/app/data
restart: unless-stopped restart: unless-stopped
+204 -7
View File
@@ -1,17 +1,214 @@
#!/bin/sh #!/bin/sh
set -e set -e
DATA_FILE=/app/data/admin-content.json DATA_DIR=${SITEFORGE_DATA_DIR:-/app/data}
SEED_FILE=/app/data-seed/admin-content.json 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 /app/data mkdir -p "$DATA_DIR"
# Only seed from image defaults when no live data file exists yet. # 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.
if [ ! -f "$DATA_FILE" ] && [ -f "$SEED_FILE" ]; then if [ -d "$SEED_DIR" ]; then
echo "[siteforge] No admin-content.json found. Seeding from image defaults..." for seed_file in "$SEED_DIR"/*.json; do
cp "$SEED_FILE" "$DATA_FILE" [ -f "$seed_file" ] || continue
base_name=$(basename "$seed_file")
target_file="$DATA_DIR/$base_name"
if [ ! -f "$target_file" ]; then
echo "[siteforge] No $base_name found. Seeding from image defaults..."
cp "$seed_file" "$target_file"
fi
done
fi fi
# Merge newly added seeded platform links into existing admin content on upgrades
# without overwriting user-edited values already present in the live volume.
merge_seed_platform_links() {
target_file="$1"
seed_file="$2"
[ -f "$target_file" ] || return 0
[ -f "$seed_file" ] || return 0
node - "$target_file" "$seed_file" <<'NODE'
const fs = require('fs')
const [targetFile, seedFile] = process.argv.slice(2)
function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'))
}
function normalizeLinks(value) {
return Array.isArray(value) ? value.filter(item => item && typeof item === 'object') : []
}
const target = readJson(targetFile)
const seed = readJson(seedFile)
if (!target.siteContent || !seed.siteContent) process.exit(0)
const targetLinks = normalizeLinks(target.siteContent.customLinks)
const seedLinks = normalizeLinks(seed.siteContent.customLinks)
let changed = false
for (const seedLink of seedLinks) {
if (seedLink.placement !== 'platforms' || !seedLink.id || !seedLink.label || !seedLink.url) continue
const existingIndex = targetLinks.findIndex(link => link.id === seedLink.id)
if (existingIndex === -1) {
targetLinks.push(seedLink)
changed = true
continue
}
const existing = targetLinks[existingIndex]
const merged = { ...existing }
if (!merged.imageUrl && seedLink.imageUrl) {
merged.imageUrl = seedLink.imageUrl
changed = true
}
if (!merged.placement && seedLink.placement) {
merged.placement = seedLink.placement
changed = true
}
if (!merged.label && seedLink.label) {
merged.label = seedLink.label
changed = true
}
if (!merged.url && seedLink.url) {
merged.url = seedLink.url
changed = true
}
targetLinks[existingIndex] = merged
}
if (!changed) process.exit(0)
target.siteContent.customLinks = targetLinks
fs.writeFileSync(targetFile, `${JSON.stringify(target, null, 2)}\n`)
console.log(`[siteforge] Merged seeded platform links into ${targetFile}`)
NODE
}
merge_seed_platform_links "$DATA_DIR/admin-content.json" "$SEED_DIR/admin-content.json"
merge_seed_platform_links "$DATA_DIR/admin-content-draft.json" "$SEED_DIR/admin-content-draft.json"
# Backfill missing lesson releasedAt values from seeded defaults on upgrades.
# This keeps persistent volumes compatible with release-date access gating.
merge_seed_study_release_dates() {
target_file="$1"
seed_file="$2"
[ -f "$target_file" ] || return 0
[ -f "$seed_file" ] || return 0
node - "$target_file" "$seed_file" <<'NODE'
const fs = require('fs')
const [targetFile, seedFile] = process.argv.slice(2)
function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'))
}
function asArray(value) {
return Array.isArray(value) ? value : []
}
function mergeStudyDates(targetStudies, seedStudies) {
let changed = false
const seedBySlug = new Map()
for (const seedStudy of asArray(seedStudies)) {
if (!seedStudy || typeof seedStudy !== 'object') continue
if (typeof seedStudy.slug !== 'string' || !seedStudy.slug) continue
seedBySlug.set(seedStudy.slug, seedStudy)
}
for (const targetStudy of asArray(targetStudies)) {
if (!targetStudy || typeof targetStudy !== 'object') continue
if (typeof targetStudy.slug !== 'string' || !targetStudy.slug) continue
const seedStudy = seedBySlug.get(targetStudy.slug)
if (!seedStudy) continue
const seedSectionById = new Map()
for (const seedSection of asArray(seedStudy.sections)) {
if (!seedSection || typeof seedSection !== 'object') continue
if (typeof seedSection.id !== 'string' || !seedSection.id) continue
seedSectionById.set(seedSection.id, seedSection)
}
for (const targetSection of asArray(targetStudy.sections)) {
if (!targetSection || typeof targetSection !== 'object') continue
if (typeof targetSection.id !== 'string' || !targetSection.id) continue
if (typeof targetSection.releasedAt === 'string' && targetSection.releasedAt.trim()) continue
const seedSection = seedSectionById.get(targetSection.id)
if (!seedSection || typeof seedSection.releasedAt !== 'string' || !seedSection.releasedAt.trim()) continue
targetSection.releasedAt = seedSection.releasedAt
changed = true
}
}
return changed
}
function mergeLegacyColossiansDates(targetSections, seedSections) {
let changed = false
const seedById = new Map()
for (const seedSection of asArray(seedSections)) {
if (!seedSection || typeof seedSection !== 'object') continue
if (typeof seedSection.id !== 'string' || !seedSection.id) continue
seedById.set(seedSection.id, seedSection)
}
for (const targetSection of asArray(targetSections)) {
if (!targetSection || typeof targetSection !== 'object') continue
if (typeof targetSection.id !== 'string' || !targetSection.id) continue
if (typeof targetSection.releasedAt === 'string' && targetSection.releasedAt.trim()) continue
const seedSection = seedById.get(targetSection.id)
if (!seedSection || typeof seedSection.releasedAt !== 'string' || !seedSection.releasedAt.trim()) continue
targetSection.releasedAt = seedSection.releasedAt
changed = true
}
return changed
}
const target = readJson(targetFile)
const seed = readJson(seedFile)
if (!target.siteContent || !seed.siteContent) process.exit(0)
const targetContent = target.siteContent
const seedContent = seed.siteContent
const changedStudies = mergeStudyDates(targetContent.studies, seedContent.studies)
const changedLegacy = mergeLegacyColossiansDates(targetContent.colossiansStudySections, seedContent.colossiansStudySections)
if (!changedStudies && !changedLegacy) process.exit(0)
fs.writeFileSync(targetFile, `${JSON.stringify(target, null, 2)}\n`)
console.log(`[siteforge] Backfilled missing releasedAt values in ${targetFile}`)
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-draft.json" "$SEED_DIR/admin-content-draft.json"
export SITEFORGE_DATA_DIR="$DATA_DIR"
exec node server.js exec node server.js
+5 -5
View File
@@ -11,17 +11,17 @@
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:title" content="Verse by Verse with Nate" /> <meta property="og:title" content="Verse by Verse with Nate" />
<meta property="og:description" content="A verse-by-verse Scripture podcast exploring God's Word with depth, clarity, and practical application." /> <meta property="og:description" content="A verse-by-verse Scripture podcast exploring God's Word with depth, clarity, and practical application." />
<meta property="og:image" content="https://necloud.us/images/banner.png" /> <meta property="og:image" content="https://versebyversewithnate.us/images/banner.png" />
<meta property="og:image:type" content="image/png" /> <meta property="og:image:type" content="image/png" />
<meta property="og:image:secure_url" content="https://necloud.us/images/banner.png" /> <meta property="og:image:secure_url" content="https://versebyversewithnate.us/images/banner.png" />
<meta property="og:image:alt" content="Verse by Verse with Nate podcast banner" /> <meta property="og:image:alt" content="Verse by Verse with Nate podcast banner" />
<meta property="og:locale" content="en_US" /> <meta property="og:locale" content="en_US" />
<meta property="og:url" content="https://necloud.us" /> <meta property="og:url" content="https://versebyversewithnate.us" />
<meta property="og:site_name" content="Verse by Verse with Nate" /> <meta property="og:site_name" content="Verse by Verse with Nate" />
<link rel="canonical" href="https://necloud.us" /> <link rel="canonical" href="https://versebyversewithnate.us" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;0,900;1,700&family=Barlow+Condensed:wght@300;400;500;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,600;1,700&family=Lora:ital,wght@0,400;0,500;0,600;1,400;1,500&display=swap" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+6191 -109
View File
File diff suppressed because it is too large Load Diff
+24 -5
View File
@@ -1,28 +1,46 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "0.0.0", "version": "1.1.40",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"api": "node server.js", "api": "node --env-file=.env server.js",
"dev:full": "concurrently \"npm:api\" \"npm:dev\"", "dev:full": "concurrently \"npm:api\" \"npm:dev\"",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"start": "node server.js", "start": "node --env-file=.env server.js",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tiptap/react": "^3.27.0",
"@tiptap/starter-kit": "^3.27.0",
"chart.js": "^4.4.0",
"docx": "^9.6.1",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.4.1",
"fuse.js": "^7.4.2",
"mammoth": "^1.12.0",
"otplib": "^13.4.0",
"prop-types": "^15.8.1",
"qrcode": "^1.5.4",
"react": "^19.2.4", "react": "^19.2.4",
"react-chartjs-2": "^5.2.0",
"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",
"remark-gfm": "^4.0.1" "react-simple-maps": "^3.0.0",
"remark-gfm": "^4.0.1",
"resend": "^6.10.0",
"tar": "^7.5.19"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@types/node": "^24.12.0", "@types/node": "^24.12.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0", "@vitejs/plugin-react": "^6.0.0",
@@ -33,6 +51,7 @@
"globals": "^17.4.0", "globals": "^17.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.56.1", "typescript-eslint": "^8.56.1",
"vite": "^8.0.0" "vite": "^8.0.0",
"vite-plugin-pwa": "^1.3.0"
} }
} }
+156
View File
@@ -0,0 +1,156 @@
# Finished Books — Episode Playlist Feature
## Goal
Add a proper **Finished Books** section where completed book studies live with a full ordered episode playlist. The Downloads page gets a minor label/copy update (Option A). A single "End Current Series" button in admin handles the full book transition in one step.
---
## User Flow (Public Site)
1. User clicks **Finished Books** in the nav
2. Lands on `/finished` — grid of completed book cards (image, title, description)
3. Clicks a book → `/finished/:id` — sees all episodes in numbered order, each with a player
4. Breadcrumb: `Finished Books → Titus`
**Episode Highlights** on the Episodes page only ever shows picks from the current book. When a book ends, highlights are cleared and replaced with picks from the new series automatically (see transition workflow below).
---
## Book Transition Workflow — "End Current Series" Button
A single button in admin (lives on the Current Series tab) that does all three transition steps at once:
### What it does
1. **Creates a Finished Book entry** from the current series data (title, image, description, season number) — immediately appears on `/finished`
2. **Clears Episode Highlights** — wipes `podcastFeaturedLinks` so the old season's picks are gone
3. **Opens a guided form** for the new series — prompts for new series title, image URL, and season number before saving
### The form flow
- User clicks **"End Current Series & Start New Book"**
- A confirmation modal appears with a summary: "This will archive [Titus] to Finished Books and clear Episode Highlights."
- Below the confirmation, a small form: New Series Title, New Series Image, New Season Number
- User fills it in and hits **Confirm**
- All three changes save together as one content update
### What the user still does manually after
- Go to Episode Highlights and add 35 hand-picked episodes from the new series (the section is now empty and ready)
---
## Changes Required
### 1. `server/routes/episodes.js` — Parse `itunes:season` from RSS
One line added to `parseRssItems` alongside the existing `itunes:episode` extraction:
```js
const season = (/<itunes:season>([\s\S]*?)<\/itunes:season>/.exec(block)?.[1] ?? '').trim()
```
Include `season` in the returned episode object.
### 2. `src/content.ts` — New `FinishedBook` type + field on `SiteContent`
Clean, purpose-built interface:
```ts
export interface FinishedBook {
id: string
title: string
description: string
imageUrl: string
season: number
}
```
Add `finishedBooks: FinishedBook[]` to `SiteContent` with default `[]`.
### 3. `src/App.tsx` — New `useEpisodesForBook` hook
Fetches `/api/episodes/all` and filters by season number, sorted ascending:
```js
const filtered = (data.episodes ?? [])
.filter(ep => ep.season === String(book.season))
.sort((a, b) => parseInt(a.episode) - parseInt(b.episode))
```
### 4. `src/App.tsx` — Nav: "Episodes" dropdown replacing flat link
"Episodes" becomes a dropdown parent with two children: **Current Series** (`/episodes`) and **Finished Books** (`/finished`). The flat `<NavLink to="/episodes">` is replaced with a dropdown component. Everything else in the nav stays flat and unchanged.
**Desktop behavior:**
- "Episodes ▾" shows on hover (or focus), revealing a small popover menu below with the two links
- Parent label stays gold/active when either child route is active
- Implemented with CSS `:hover` + `:focus-within` on a wrapper — no JS state needed for desktop
**Mobile behavior (under ~540px):**
- The mobile nav already collapses to a full-width vertical list with `max-height` animation
- "Episodes" becomes a top-level row with an expand arrow (chevron)
- Tapping it toggles a sub-list showing "Current Series" and "Finished Books" indented beneath it
- Implemented with a small React `useState` toggle on the dropdown item — consistent with how `menuOpen` already works
- Sub-items get slightly smaller text and left padding to show hierarchy
- The `max-height: 500px` on `.header-nav--open` will need to increase to `600px` to accommodate the expanded sub-items
**New elements needed:**
- `NavDropdown` component (or inline JSX) wrapping the two episode links
- CSS classes: `.nav-dropdown`, `.nav-dropdown-menu`, `.nav-dropdown-item`, `.nav-dropdown--open` (mobile only)
- Mobile sub-item styles: indented, smaller text, no bottom border on last child before parent closes
**Footer:** Add a flat "Finished Books" link alongside the existing footer nav links — no dropdown needed there.
### 5. `src/App.tsx` — Two new routes
```tsx
<Route path="/finished" element={<FinishedBooksPage content={content} />} />
<Route path="/finished/:id" element={<FinishedSeriesPage content={content} />} />
```
### 6. `src/App.tsx` — Two new page components
**`FinishedBooksPage`** — grid of `finishedBooks` cards (image, title, description), each linking to `/finished/:id`. Friendly empty state if no books yet.
**`FinishedSeriesPage`** — uses `useEpisodesForBook`, renders episodes sorted ascending by episode number as a numbered playlist. Each row: episode number, title, duration, `EpisodeAudioPlayer`.
### 7. `src/AdminPage.tsx` — "End Current Series" button on Current Series tab
- Button labeled **"End Current Series & Start New Book"**
- Opens an inline confirmation + new series form (not a browser `confirm()` — a proper UI panel)
- On confirm: creates `FinishedBook` from current series data, clears `podcastFeaturedLinks`, updates series fields with new book info
- All saved as one atomic content update
### 8. `src/AdminPage.tsx` — Finished Books management section
Separate tab for viewing and managing the finished books list (in case you need to edit or remove one):
- Shows each finished book as a collapsible card
- Edit title, description, image, season number
- Remove button
- "Add Finished Book" button for manually adding one without going through the transition wizard
### 9. CSS — Playlist and finished books grid styles
Reuse existing episode-list and episode-card patterns where possible.
### 10. `src/App.tsx` — Downloads page copy tweak (Option A, low priority)
Minor label changes only — no structural change:
- Eyebrow becomes "Current Study"
- Heading becomes `{content.seriesTitle} — Study Guide`
- Everything else (download form, Amazon button, library) stays identical
---
## Prerequisite — Verify RSS Season Tags
Before starting, confirm Titus episodes have `<itunes:season>` tags and what number they use:
```
curl -s https://anchor.fm/s/11068d290/podcast/rss | grep -o '<itunes:season>[^<]*</itunes:season>' | head -5
```
If seasons are missing from the RSS, the fallback is an `episodeRange: { from, to }` field on `FinishedBook` instead of `season`.
---
## Files Touched Summary
| File | What changes |
|---|---|
| `server/routes/episodes.js` | Parse `itunes:season` into episode objects |
| `src/content.ts` | New `FinishedBook` interface + `finishedBooks` field on `SiteContent` |
| `src/App.tsx` | New hook, nav links, 2 routes, 2 page components, Downloads copy tweak |
| `src/AdminPage.tsx` | "End Current Series" transition button + Finished Books management tab |
| CSS | Playlist page + finished books grid styles |
---
## Order of Work
1. Run the RSS curl check to confirm season tags (5 min)
2. `content.ts` — add `FinishedBook` type and `finishedBooks` field
3. `server/routes/episodes.js` — parse season from RSS
4. Admin — "End Current Series" transition button + Finished Books management tab
5. Public pages — nav link, routes, `FinishedBooksPage`, `FinishedSeriesPage`
6. Downloads copy tweak (last, lowest priority)
+18
View File
@@ -0,0 +1,18 @@
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
@@ -0,0 +1,63 @@
# 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
@@ -0,0 +1,12 @@
{
"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
@@ -0,0 +1,401 @@
<!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 1115</div>`;
titus.forEach(ep => html += renderCard(ep));
}
if (colossians.length) {
html += `<div class="section-label">Colossians — Episodes 127</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
@@ -0,0 +1,43 @@
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.

After

Width:  |  Height:  |  Size: 89 KiB

+19 -1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="184" height="48" viewBox="0 0 184 48">
<rect width="184" height="48" rx="6" fill="#1877F2"/>
<g transform="translate(11,11)">
<circle cx="13" cy="13" r="12" fill="rgba(255,255,255,0.16)"/>
<path fill="#ffffff" d="M17.755 13.037C17.755 10.385 15.617 8.237 12.98 8.237c-2.638 0-4.776 2.148-4.776 4.8 0 2.396 1.744 4.383 4.028 4.743v-3.36H11.01v-1.383h1.222v-1.054c0-1.215.719-1.887 1.82-1.887.527 0 1.078.094 1.078.094v1.193h-.608c-.598 0-.784.373-.784.755v.899h1.334l-.213 1.383h-1.121v3.36c2.284-.36 4.017-2.347 4.017-4.743z"/>
</g>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="rgba(255,255,255,0.82)" font-size="9.5" x="52" y="19">Follow on</text>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#ffffff" font-size="16" font-weight="bold" x="52" y="36">Facebook</text>
</svg>

After

Width:  |  Height:  |  Size: 891 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="210" height="48" viewBox="0 0 210 48">
<rect width="210" height="48" rx="6" fill="#131921"/>
<!-- Amazon Music icon: headphone/music note style - simple "a" with music mark -->
<g transform="translate(11,11)">
<!-- Amazon arrow swoosh shape -->
<circle cx="13" cy="13" r="12" fill="#00A8E1"/>
<!-- Simplified headphone icon in white -->
<path fill="#ffffff" d="M13 4C8.03 4 4 8.03 4 13v1h2v-1c0-3.87 3.13-7 7-7s7 3.13 7 7v1h2v-1c0-4.97-4.03-9-9-9zm-3 10H8c-.55 0-1 .45-1 1v3c0 .55.45 1 1 1h2c.55 0 1-.45 1-1v-3c0-.55-.45-1-1-1zm6 0h-2c-.55 0-1 .45-1 1v3c0 .55.45 1 1 1h2c.55 0 1-.45 1-1v-3c0-.55-.45-1-1-1z"/>
</g>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#b3b3b3" font-size="9.5" x="52" y="19">Listen on</text>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#ffffff" font-size="15" font-weight="bold" x="52" y="36">Amazon Music</text>
</svg>

After

Width:  |  Height:  |  Size: 972 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="183" height="48" viewBox="0 0 183 48">
<rect width="183" height="48" rx="6" fill="#1a1a1a"/>
<!-- Castbox icon: headphones/podcast icon in orange -->
<g transform="translate(11,11)">
<circle cx="13" cy="13" r="12" fill="#F55223"/>
<!-- Headphones shape in white -->
<path fill="#ffffff" d="M13 5C8.58 5 5 8.58 5 13v.5H7V13c0-3.31 2.69-6 6-6s6 2.69 6 6v.5h2V13c0-4.42-3.58-8-8-8zm-3.5 9H7c-.55 0-1 .45-1 1v2.5c0 .55.45 1 1 1h2.5c.55 0 1-.45 1-1V15c0-.55-.45-1-1-1zm7 0h-2.5c-.55 0-1 .45-1 1v2.5c0 .55.45 1 1 1H17c.55 0 1-.45 1-1V15c0-.55-.45-1-1-1z"/>
</g>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#b3b3b3" font-size="9.5" x="52" y="19">Listen on</text>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#ffffff" font-size="16" font-weight="bold" x="52" y="36">Castbox</text>
</svg>

After

Width:  |  Height:  |  Size: 904 B

+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" width="195" height="48" viewBox="0 0 195 48">
<rect width="195" height="48" rx="6" fill="#F43E37"/>
<!-- Pocket Casts logo: circle with play-in-wave icon -->
<g transform="translate(11,11)">
<circle cx="13" cy="13" r="12" fill="rgba(0,0,0,0.25)"/>
<!-- Outer arc -->
<path fill="#ffffff" d="M13 2C7 2 2 7 2 13s5 11 11 11 11-5 11-11S19 2 13 2zm0 19c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/>
<!-- Inner arc -->
<path fill="#ffffff" d="M13 6c-3.87 0-7 3.13-7 7s3.13 7 7 7 7-3.13 7-7-3.13-7-7-7zm0 11c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4z"/>
<!-- Center dot -->
<circle cx="13" cy="13" r="2.5" fill="#ffffff"/>
</g>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="rgba(255,255,255,0.8)" font-size="9.5" x="52" y="19">Listen on</text>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#ffffff" font-size="14.5" font-weight="bold" x="52" y="36">Pocket Casts</text>
</svg>

After

Width:  |  Height:  |  Size: 1019 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="48" viewBox="0 0 180 48">
<rect width="180" height="48" rx="6" fill="#191414"/>
<!-- Spotify icon (24x24 viewBox path, scaled to fit 26x26 at offset 11,11) -->
<g transform="translate(11,11) scale(1.083)">
<path fill="#1DB954" d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/>
</g>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#b3b3b3" font-size="9.5" x="48" y="19">Listen on</text>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#ffffff" font-size="16" font-weight="bold" x="48" y="36">Spotify</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="190" height="48" viewBox="0 0 190 48">
<rect width="190" height="48" rx="6" fill="#0f0f0f"/>
<!-- YouTube icon: red rounded rect with white play triangle -->
<g transform="translate(11,11)">
<!-- YouTube red background rect -->
<rect x="1" y="4.5" width="24" height="17" rx="4" fill="#FF0000"/>
<!-- White play triangle -->
<polygon points="10,9 20,13 10,17" fill="#ffffff"/>
</g>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#b3b3b3" font-size="9.5" x="52" y="19">Watch on</text>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#ffffff" font-size="16" font-weight="bold" x="52" y="36">YouTube</text>
</svg>

After

Width:  |  Height:  |  Size: 732 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

+9
View File
@@ -0,0 +1,9 @@
User-agent: *
Allow: /
Disallow: /admin
Disallow: /api/
Disallow: /thanks
Disallow: /subscribe/thanks
Sitemap: https://versebyversewithnate.us/sitemap.xml
+899
View File
@@ -0,0 +1,899 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gospel Harmony — The Trial & Crucifixion</title>
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700;900&family=EB+Garamond:ital,wght@0,400;0,500;0,600;1,400;1,500&display=swap" rel="stylesheet">
<style>
:root {
--parchment: #f5edd8;
--parchment-dark: #e8d9b8;
--ink: #1a1208;
--ink-light: #3d2c10;
--crimson: #8b1a1a;
--gold: #b8860b;
--gold-light: #d4a843;
--matt-color: #4a6fa5;
--mark-color: #7a4a8f;
--luke-color: #2e7d52;
--john-color: #b5651d;
--shadow: rgba(26,18,8,0.18);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #1a0f05;
font-family: 'EB Garamond', Georgia, serif;
color: var(--ink);
min-height: 100vh;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
inset: 0;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='400' height='400' filter='url(%23n)' opacity='0.07'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 0;
opacity: 0.5;
}
.scroll-wrapper {
max-width: 960px;
margin: 0 auto;
padding: 0 1.5rem 4rem;
position: relative;
z-index: 1;
}
header {
text-align: center;
padding: 3.5rem 2rem 2rem;
}
.header-rule {
height: 3px;
background: linear-gradient(90deg, transparent, var(--gold), var(--crimson), var(--gold), transparent);
margin-bottom: 1.8rem;
position: relative;
}
.header-rule::before, .header-rule::after {
content: '✦';
position: absolute;
top: 50%;
transform: translateY(-50%);
color: var(--gold);
font-size: 0.9rem;
background: #1a0f05;
padding: 0 0.5rem;
}
.header-rule::before { left: 20%; }
.header-rule::after { right: 20%; }
header h1 {
font-family: 'Cinzel', serif;
font-size: clamp(1.6rem, 5vw, 3rem);
font-weight: 900;
color: var(--parchment);
letter-spacing: 0.06em;
text-shadow: 0 2px 8px rgba(0,0,0,0.5);
line-height: 1.15;
}
header h1 span { color: var(--gold-light); }
header .subtitle {
font-family: 'EB Garamond', serif;
font-style: italic;
color: var(--parchment-dark);
font-size: 1.1rem;
margin-top: 0.6rem;
opacity: 0.85;
}
.legend {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.6rem 1.4rem;
margin: 2rem auto 1rem;
padding: 1rem 1.5rem;
background: rgba(245,237,216,0.06);
border: 1px solid rgba(184,134,11,0.3);
border-radius: 4px;
max-width: 700px;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.45rem;
font-family: 'Cinzel', serif;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
cursor: pointer;
transition: opacity 0.2s;
color: var(--parchment);
user-select: none;
}
.legend-item.dimmed { opacity: 0.35; }
.legend-dot {
width: 14px; height: 14px;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.3);
flex-shrink: 0;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
margin: 1rem 0 2.5rem;
}
.filter-btn {
font-family: 'Cinzel', serif;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.1em;
padding: 0.35rem 0.9rem;
border-radius: 2px;
border: 1px solid;
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
}
.filter-btn.active-filter { color: var(--ink); font-weight: 700; }
.filter-btn:not(.active-filter) {
background: transparent;
color: var(--parchment-dark);
opacity: 0.55;
}
.filter-btn:hover { opacity: 1; }
.timeline {
position: relative;
padding-left: 2.5rem;
}
.timeline::before {
content: '';
position: absolute;
left: 0.95rem;
top: 0; bottom: 0;
width: 2px;
background: linear-gradient(180deg, var(--gold) 0%, var(--crimson) 50%, var(--gold) 100%);
opacity: 0.5;
}
.phase-header {
position: relative;
margin: 2.5rem 0 1rem;
display: flex;
align-items: center;
gap: 1rem;
}
.phase-header::before {
content: '';
position: absolute;
left: -2.5rem;
width: 2rem;
height: 2px;
background: var(--gold);
}
.phase-node {
width: 1.8rem; height: 1.8rem;
border-radius: 50%;
background: linear-gradient(135deg, var(--gold), var(--crimson));
border: 3px solid var(--parchment-dark);
position: absolute;
left: -2.42rem;
top: 50%;
transform: translateY(-50%);
box-shadow: 0 0 12px rgba(184,134,11,0.5);
z-index: 2;
}
.phase-label {
font-family: 'Cinzel', serif;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--gold-light);
padding: 0.2rem 0.8rem;
border: 1px solid rgba(184,134,11,0.4);
border-radius: 2px;
background: rgba(26,9,5,0.6);
}
.phase-title {
font-family: 'Cinzel', serif;
font-size: 1.05rem;
font-weight: 700;
color: var(--parchment);
letter-spacing: 0.04em;
}
.event-card {
position: relative;
margin: 0.65rem 0;
background: linear-gradient(135deg, rgba(245,237,216,0.96) 0%, rgba(232,217,184,0.94) 100%);
border-radius: 4px;
padding: 1rem 1.2rem 1rem 1.4rem;
border-left: 4px solid var(--crimson);
box-shadow: 2px 3px 14px var(--shadow), inset 0 1px 0 rgba(255,255,255,0.4);
transition: transform 0.18s, box-shadow 0.18s, opacity 0.25s;
}
.event-card:hover {
transform: translateX(3px);
box-shadow: 4px 5px 20px rgba(26,18,8,0.28);
}
.event-card.hidden { display: none; }
.event-card::before {
content: '';
position: absolute;
left: -2.09rem;
top: 1.1rem;
width: 10px; height: 10px;
border-radius: 50%;
background: var(--parchment);
border: 2px solid var(--crimson);
box-shadow: 0 0 0 2px rgba(26,18,8,0.15);
}
.event-title {
font-family: 'Cinzel', serif;
font-size: 0.88rem;
font-weight: 700;
color: var(--ink);
letter-spacing: 0.04em;
margin-bottom: 0.35rem;
line-height: 1.3;
}
.event-body {
font-size: 0.9rem;
color: var(--ink-light);
line-height: 1.6;
font-style: italic;
margin-bottom: 0.5rem;
}
.gospel-badges {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.4rem;
}
/* shared badge base */
.badge {
font-family: 'Cinzel', serif;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.1em;
padding: 0.15rem 0.55rem;
border-radius: 2px;
border: 1px solid;
color: white;
text-decoration: none;
display: inline-block;
cursor: default;
}
a.badge {
cursor: pointer;
transition: filter 0.15s, transform 0.15s;
}
a.badge:hover {
filter: brightness(1.25);
transform: translateY(-1px);
}
.badge-matt { background: var(--matt-color); border-color: #3a5a8a; }
.badge-mark { background: var(--mark-color); border-color: #6a3a7f; }
.badge-luke { background: var(--luke-color); border-color: #1e6040; }
.badge-john { background: var(--john-color); border-color: #8b4513; }
.ref-text {
font-size: 0.72rem;
color: #7a5a2a;
margin-top: 0.35rem;
font-style: normal;
letter-spacing: 0.02em;
}
.unique-note {
font-size: 0.75rem;
font-style: italic;
color: var(--crimson);
margin-top: 0.3rem;
padding-top: 0.25rem;
border-top: 1px dashed rgba(139,26,26,0.25);
}
.bsb-hint {
font-size: 0.68rem;
color: #9a7a3a;
margin-top: 0.4rem;
font-style: italic;
opacity: 0.8;
}
footer {
text-align: center;
padding: 3rem 1rem 1.5rem;
font-size: 0.8rem;
color: rgba(245,237,216,0.35);
font-style: italic;
font-family: 'EB Garamond', serif;
letter-spacing: 0.04em;
}
@media (max-width: 600px) {
.timeline { padding-left: 2rem; }
.timeline::before { left: 0.7rem; }
.event-card::before { left: -1.6rem; }
.phase-node { left: -1.92rem; }
.phase-header::before { left: -2rem; width: 1.6rem; }
.event-card { padding: 0.9rem 1rem 0.9rem 1.1rem; }
}
</style>
</head>
<body>
<div class="scroll-wrapper">
<header>
<div class="header-rule"></div>
<h1>A <span>Gospel Harmony</span><br>The Trial &amp; Crucifixion of Jesus</h1>
<p class="subtitle">Matthew · Mark · Luke · John — Woven into One Timeline</p>
<div class="header-rule" style="margin-top:1.6rem;margin-bottom:0"></div>
</header>
<div class="legend">
<div class="legend-item" data-gospel="matt" onclick="toggleGospel('matt',this)">
<div class="legend-dot" style="background:var(--matt-color)"></div> Matthew
</div>
<div class="legend-item" data-gospel="mark" onclick="toggleGospel('mark',this)">
<div class="legend-dot" style="background:var(--mark-color)"></div> Mark
</div>
<div class="legend-item" data-gospel="luke" onclick="toggleGospel('luke',this)">
<div class="legend-dot" style="background:var(--luke-color)"></div> Luke
</div>
<div class="legend-item" data-gospel="john" onclick="toggleGospel('john',this)">
<div class="legend-dot" style="background:var(--john-color)"></div> John
</div>
</div>
<div class="filter-bar">
<button class="filter-btn active-filter" style="border-color:var(--gold);background:var(--gold);color:var(--ink)" onclick="filterPhase('all',this)">All Events</button>
<button class="filter-btn" style="border-color:#555" onclick="filterPhase('arrest',this)">Arrest</button>
<button class="filter-btn" style="border-color:#555" onclick="filterPhase('jewish-trial',this)">Jewish Trial</button>
<button class="filter-btn" style="border-color:#555" onclick="filterPhase('roman-trial',this)">Roman Trial</button>
<button class="filter-btn" style="border-color:#555" onclick="filterPhase('crucifixion',this)">Crucifixion</button>
<button class="filter-btn" style="border-color:#555" onclick="filterPhase('burial',this)">Burial</button>
</div>
<div class="timeline" id="timeline"></div>
<footer>
A harmonic arrangement of Matthew 2627, Mark 1415, Luke 2223, and John 1819.<br>
Click any Gospel badge to open that passage in the Berean Standard Bible (BSB) at BibleHub.
</footer>
</div>
<script>
// BSB URL helper — BibleHub format: https://biblehub.com/bsb/matthew/26.htm
const BSB = {
matt: (ch) => `https://biblehub.com/bsb/matthew/${ch}.htm`,
mark: (ch) => `https://biblehub.com/bsb/mark/${ch}.htm`,
luke: (ch) => `https://biblehub.com/bsb/luke/${ch}.htm`,
john: (ch) => `https://biblehub.com/bsb/john/${ch}.htm`,
};
const GOSPELS = ['matt','mark','luke','john'];
const activeGospels = new Set(GOSPELS);
let activePhase = 'all';
const data = [
// ── PHASE 1: ARREST ──────────────────────────────────────────
{ phase:'arrest', phaseLabel:'Phase I', phaseTitle:'The Arrest in Gethsemane', events:[
{
title:'The Last Supper & Passover Meal',
body:"Jesus eats the Passover meal with his twelve disciples, institutes the Lord's Supper, and predicts his betrayal.",
gospels:['matt','mark','luke','john'],
refs:'Matt 26:2029 · Mark 14:1725 · Luke 22:1423 · John 13:130',
unique:"John alone records Jesus washing the disciples' feet and the extended farewell discourse (John 1317).",
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22), john:BSB.john(13) }
},
{
title:"Jesus Predicts Peter's Denial",
body:"Jesus tells Peter that before the rooster crows, he will deny him three times.",
gospels:['matt','mark','luke','john'],
refs:'Matt 26:3135 · Mark 14:2731 · Luke 22:3134 · John 13:3638',
unique:'Luke adds that Jesus prayed specifically for Peter that his faith would not fail.',
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22), john:BSB.john(13) }
},
{
title:'The Agony in Gethsemane',
body:'Jesus prays alone in the garden in great anguish: "Father, if you are willing, remove this cup from me; nevertheless, not my will, but yours, be done."',
gospels:['matt','mark','luke'],
refs:'Matt 26:3646 · Mark 14:3242 · Luke 22:3946',
unique:'Luke alone records that an angel appeared to strengthen Jesus, and that his sweat became like drops of blood.',
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22) }
},
{
title:'Judas Arrives with the Crowd',
body:'Judas leads a crowd with swords and clubs, sent from the chief priests and elders, into the garden.',
gospels:['matt','mark','luke','john'],
refs:'Matt 26:47 · Mark 14:43 · Luke 22:47 · John 18:3',
unique:'John specifies it was a Roman cohort plus Jewish officers, and that they carried lanterns and torches.',
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22), john:BSB.john(18) }
},
{
title:"The Betrayer's Kiss",
body:"Judas greets Jesus with a kiss as the prearranged sign of identification.",
gospels:['matt','mark','luke'],
refs:'Matt 26:4849 · Mark 14:4445 · Luke 22:4748',
unique:'John omits the kiss; instead Jesus steps forward and declares "I am he," causing the crowd to fall to the ground.',
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22) }
},
{
title:'Jesus Identifies Himself — "I Am He" (John)',
body:'Jesus asks "Whom do you seek?" When they say "Jesus of Nazareth," he says "I am he," and the entire crowd falls backward to the ground.',
gospels:['john'],
refs:'John 18:49',
unique:'Unique to John — a powerful "I Am" moment that echoes the divine name.',
bsbLinks:{ john:BSB.john(18) }
},
{
title:"The Servant's Ear Cut Off",
body:"One of those with Jesus draws a sword and cuts off the right ear of the high priest's servant, Malchus.",
gospels:['matt','mark','luke','john'],
refs:'Matt 26:51 · Mark 14:47 · Luke 22:50 · John 18:10',
unique:'Only John names the attacker as Peter and the servant as Malchus. Only Luke records that Jesus healed the ear.',
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22), john:BSB.john(18) }
},
{
title:'Jesus Rebukes the Sword & Surrenders',
body:'"Put your sword back in its place. Shall I not drink the cup the Father has given me?" Then all the disciples fled.',
gospels:['matt','mark','luke','john'],
refs:'Matt 26:5256 · Mark 14:4850 · Luke 22:5153 · John 18:11',
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22), john:BSB.john(18) }
},
{
title:'The Young Man Who Fled (Mark)',
body:'A young man follows Jesus wearing only a linen cloth; when seized, he leaves the cloth behind and flees naked.',
gospels:['mark'],
refs:'Mark 14:5152',
unique:'Unique to Mark — possibly a self-reference by the author.',
bsbLinks:{ mark:BSB.mark(14) }
},
]},
// ── PHASE 2: JEWISH TRIAL ─────────────────────────────────────
{ phase:'jewish-trial', phaseLabel:'Phase II', phaseTitle:'The Jewish Trial', events:[
{
title:'Jesus Led to Annas (John)',
body:"Jesus is first brought to Annas, father-in-law of Caiaphas, who questions Jesus about his disciples and teaching.",
gospels:['john'],
refs:'John 18:1214, 1924',
unique:'Unique to John. After the interrogation, Annas sends Jesus bound to Caiaphas.',
bsbLinks:{ john:BSB.john(18) }
},
{
title:"Peter Follows to the Courtyard",
body:"Peter and another disciple follow Jesus to the high priest's courtyard. Peter stands warming himself by the fire.",
gospels:['matt','mark','luke','john'],
refs:'Matt 26:58 · Mark 14:54 · Luke 22:5455 · John 18:1516',
unique:"John identifies 'another disciple' known to the high priest who helped Peter gain entry.",
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22), john:BSB.john(18) }
},
{
title:'The Trial Before Caiaphas & the Sanhedrin',
body:"The Sanhedrin assembles and seeks false testimony against Jesus. Many false witnesses come, but their testimony does not agree.",
gospels:['matt','mark'],
refs:'Matt 26:57, 5961 · Mark 14:53, 5559',
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14) }
},
{
title:'"Are You the Christ?" — The High Priest\'s Question',
body:'"I adjure you by the living God, tell us if you are the Christ, the Son of God." Jesus answered, "You have said so. But I tell you, from now on you will see the Son of Man seated at the right hand of Power."',
gospels:['matt','mark','luke'],
refs:'Matt 26:6364 · Mark 14:6162 · Luke 22:6670',
unique:"Luke places a formal Sanhedrin inquiry at daybreak. Mark records Jesus' most direct 'I am' reply.",
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22) }
},
{
title:'The Verdict: Blasphemy — Deserving Death',
body:'The high priest tears his robes: "He has uttered blasphemy!" The assembly declares Jesus deserves death and begins spitting on him, striking him, and mocking him.',
gospels:['matt','mark','luke'],
refs:'Matt 26:6568 · Mark 14:6365 · Luke 22:6365',
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22) }
},
{
title:"Peter's Three Denials",
body:"As Jesus is being tried, Peter denies knowing him three times — to a servant girl, another servant, and bystanders — and the rooster crows.",
gospels:['matt','mark','luke','john'],
refs:'Matt 26:6975 · Mark 14:6672 · Luke 22:5662 · John 18:1718, 2527',
unique:'Luke alone records that Jesus turned and looked at Peter, and Peter went out and wept bitterly.',
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22), john:BSB.john(18) }
},
{
title:"Judas's Remorse & Death (Matthew)",
body:"Judas sees that Jesus has been condemned, brings the thirty pieces of silver back to the chief priests, and goes and hangs himself. The priests use the money to buy the Potter's Field.",
gospels:['matt'],
refs:'Matt 27:310',
unique:'Unique to Matthew. Acts 1:1819 offers a parallel account of Judas\'s end.',
bsbLinks:{ matt:BSB.matt(27) }
},
{
title:'Early Morning Formal Condemnation',
body:'At daybreak the chief priests and the whole council hold a formal session and bind Jesus to hand him over to Pilate.',
gospels:['matt','mark','luke'],
refs:'Matt 27:12 · Mark 15:1 · Luke 22:6623:1',
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
},
]},
// ── PHASE 3: ROMAN TRIAL ──────────────────────────────────────
{ phase:'roman-trial', phaseLabel:'Phase III', phaseTitle:'The Roman Trial Before Pilate', events:[
{
title:'Jesus Brought Before Pilate',
body:"The Jewish leaders bring Jesus to Pilate's headquarters. They do not enter so as not to be defiled before Passover. They accuse Jesus of misleading the nation, opposing taxes, and claiming to be King.",
gospels:['matt','mark','luke','john'],
refs:'Matt 27:2,11 · Mark 15:12 · Luke 23:12 · John 18:2832',
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(18) }
},
{
title:'"Are You the King of the Jews?" — Pilate\'s First Question',
body:'"Are you the King of the Jews?" Jesus replies, "You have said so" — or in John\'s longer account, "My kingdom is not of this world."',
gospels:['matt','mark','luke','john'],
refs:'Matt 27:11 · Mark 15:2 · Luke 23:3 · John 18:3337',
unique:'John records the extensive private exchange between Pilate and Jesus about kingship and truth.',
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(18) }
},
{
title:'Jesus Silent Before Accusations',
body:'As the chief priests heap on charges, Jesus gives no answer, so that Pilate is greatly amazed.',
gospels:['matt','mark'],
refs:'Matt 27:1214 · Mark 15:35',
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15) }
},
{
title:'Jesus Sent to Herod Antipas (Luke)',
body:"Learning Jesus is from Galilee, Pilate sends him to Herod, who is in Jerusalem. Herod questions Jesus at length but Jesus says nothing. Herod mocks him, dresses him in splendid clothing, and sends him back.",
gospels:['luke'],
refs:'Luke 23:612',
unique:'Unique to Luke. Luke notes this made Herod and Pilate friends that day.',
bsbLinks:{ luke:BSB.luke(23) }
},
{
title:'Pilate Declares Jesus Innocent',
body:'Pilate announces he finds no guilt in Jesus, and that neither does Herod. He offers to release one prisoner according to the custom.',
gospels:['luke','john'],
refs:'Luke 23:1316 · John 18:3819:6',
unique:"Luke alone notes three formal declarations of innocence by Pilate.",
bsbLinks:{ luke:BSB.luke(23), john:BSB.john(18) }
},
{
title:'Barabbas Released — Jesus Condemned',
body:'The crowd chooses to release Barabbas, a notorious rebel and murderer, instead of Jesus. The crowd shouts "Crucify him!"',
gospels:['matt','mark','luke','john'],
refs:'Matt 27:1523 · Mark 15:614 · Luke 23:1823 · John 18:3940',
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(18) }
},
{
title:"Pilate's Wife's Warning (Matthew)",
body:'"Have nothing to do with that righteous man, for I have suffered much over him today in a dream."',
gospels:['matt'],
refs:'Matt 27:19',
unique:'Unique to Matthew.',
bsbLinks:{ matt:BSB.matt(27) }
},
{
title:"Pilate Washes His Hands (Matthew)",
body:'Pilate takes water and washes his hands before the crowd: "I am innocent of this man\'s blood." The people respond, "His blood be on us and on our children."',
gospels:['matt'],
refs:'Matt 27:2425',
unique:'Unique to Matthew.',
bsbLinks:{ matt:BSB.matt(27) }
},
{
title:'"Behold the Man" — Ecce Homo (John)',
body:'Pilate leads Jesus out wearing the crown of thorns and a purple robe: "Behold the man!" The chief priests cry "Crucify him!"',
gospels:['john'],
refs:'John 19:47',
unique:'Unique to John.',
bsbLinks:{ john:BSB.john(19) }
},
{
title:'"Son of God" Accusation Frightens Pilate (John)',
body:'When the Jews say Jesus made himself the Son of God, Pilate is even more afraid and questions Jesus again about his origin, but Jesus gives no answer.',
gospels:['john'],
refs:'John 19:811',
unique:"Unique to John — Jesus' declaration that Pilate's authority comes from above.",
bsbLinks:{ john:BSB.john(19) }
},
{
title:"The Soldiers' Mockery",
body:"Pilate's soldiers strip Jesus, put a scarlet/purple robe on him, press a crown of thorns on his head, put a reed in his right hand, kneel before him mockingly, spit on him, and strike him.",
gospels:['matt','mark','john'],
refs:'Matt 27:2731 · Mark 15:1620 · John 19:23',
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), john:BSB.john(19) }
},
{
title:'Final Condemnation — Jesus Handed Over',
body:'Pilate, seeking to release Jesus but fearful of the cry "You are no friend of Caesar!", finally hands Jesus over to be crucified.',
gospels:['matt','mark','luke','john'],
refs:'Matt 27:26 · Mark 15:15 · Luke 23:2425 · John 19:1216',
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(19) }
},
]},
// ── PHASE 4: CRUCIFIXION ──────────────────────────────────────
{ phase:'crucifixion', phaseLabel:'Phase IV', phaseTitle:'The Way of the Cross & Crucifixion', events:[
{
title:'Jesus Carries His Cross',
body:'Jesus goes out carrying his own cross to the place called Golgotha, "The Place of a Skull."',
gospels:['matt','mark','luke','john'],
refs:'Matt 27:3132 · Mark 15:2021 · Luke 23:26 · John 19:17',
unique:'John uniquely emphasizes Jesus carried his own cross (echoing Isaac carrying wood in Gen 22). The Synoptics record Simon of Cyrene being compelled to carry it.',
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(19) }
},
{
title:'Simon of Cyrene Carries the Cross',
body:'Coming in from the country, Simon of Cyrene is seized and compelled to carry the cross behind Jesus.',
gospels:['matt','mark','luke'],
refs:'Matt 27:32 · Mark 15:21 · Luke 23:26',
unique:"Mark names Simon's sons, Alexander and Rufus, suggesting they were known to Mark's community.",
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
},
{
title:'Jesus Addresses the Daughters of Jerusalem (Luke)',
body:'"Daughters of Jerusalem, do not weep for me, but weep for yourselves and for your children... For if they do these things when the wood is green, what will happen when it is dry?"',
gospels:['luke'],
refs:'Luke 23:2731',
unique:'Unique to Luke.',
bsbLinks:{ luke:BSB.luke(23) }
},
{
title:'The Offer of Wine Mixed with Gall/Myrrh',
body:'Before the crucifixion, soldiers offer Jesus wine mixed with gall (or myrrh) — a narcotic to dull the pain — but Jesus refuses after tasting it.',
gospels:['matt','mark'],
refs:'Matt 27:34 · Mark 15:23',
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15) }
},
{
title:'The Crucifixion — The Third Hour',
body:'Jesus is crucified between two criminals. The soldiers cast lots for his garments, fulfilling Psalm 22:18.',
gospels:['matt','mark','luke','john'],
refs:'Matt 27:35 · Mark 15:2425 · Luke 23:33 · John 19:18, 2324',
unique:"John uniquely emphasizes the lot-casting fulfills Scripture and describes the tunic as seamless (echoing the high priest's garment).",
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(19) }
},
{
title:'The Title on the Cross: "King of the Jews"',
body:'Pilate has a notice written in Hebrew, Greek, and Latin: "Jesus of Nazareth, the King of the Jews."',
gospels:['matt','mark','luke','john'],
refs:'Matt 27:37 · Mark 15:26 · Luke 23:38 · John 19:1922',
unique:'John alone records the Jewish leaders\' protest and Pilate\'s firm reply: "What I have written, I have written."',
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(19) }
},
{
title:'The Mockery at the Cross',
body:'Passersby wag their heads: "You who would destroy the temple, save yourself!" The chief priests, scribes, and elders join in mocking.',
gospels:['matt','mark','luke'],
refs:'Matt 27:3944 · Mark 15:2932 · Luke 23:3537',
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
},
{
title:'One Criminal Repents — "Remember Me" (Luke)',
body:'One criminal joins the mockery. The other rebukes him and says, "Jesus, remember me when you come into your kingdom." Jesus replies, "Today you will be with me in paradise."',
gospels:['luke'],
refs:'Luke 23:3943',
unique:'Unique to Luke — the only death-row conversion recorded in the Gospels.',
bsbLinks:{ luke:BSB.luke(23) }
},
{
title:"Jesus Entrusts His Mother to the Beloved Disciple (John)",
body:'"Woman, behold your son... Behold your mother." From that hour the disciple took her into his own home.',
gospels:['john'],
refs:'John 19:2527',
unique:'Unique to John.',
bsbLinks:{ john:BSB.john(19) }
},
{
title:'Darkness Over the Land — Three Hours',
body:'From the sixth hour (noon) to the ninth hour (3 p.m.), darkness covers all the land.',
gospels:['matt','mark','luke'],
refs:'Matt 27:45 · Mark 15:33 · Luke 23:44',
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
},
{
title:'"My God, My God, Why Have You Forsaken Me?"',
body:'About the ninth hour Jesus cries out, "Eloi, Eloi, lema sabachthani?" — quoting Psalm 22:1. Some bystanders think he is calling for Elijah.',
gospels:['matt','mark'],
refs:'Matt 27:4647 · Mark 15:3435',
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15) }
},
{
title:'"I Thirst" (John)',
body:'Jesus says "I thirst." A sponge soaked in sour wine is put on a hyssop branch and offered to him.',
gospels:['john'],
refs:'John 19:2829',
unique:"John frames this as Jesus knowing 'all was now finished, to fulfill Scripture' (Ps 22:15; 69:21). The hyssop branch echoes the Passover lamb.",
bsbLinks:{ john:BSB.john(19) }
},
{
title:'"Father, Forgive Them" (Luke)',
body:'"Father, forgive them, for they know not what they do."',
gospels:['luke'],
refs:'Luke 23:34',
unique:'Unique to Luke.',
bsbLinks:{ luke:BSB.luke(23) }
},
{
title:'"It Is Finished" — Tetelestai (John)',
body:'Jesus says, "It is finished" (τετέλεσται — tetelestai, meaning "paid in full"). He bows his head and gives up his spirit.',
gospels:['john'],
refs:'John 19:30',
unique:'Unique to John — the climactic proclamation of completed redemption.',
bsbLinks:{ john:BSB.john(19) }
},
{
title:'"Father, Into Your Hands I Commit My Spirit" (Luke)',
body:'With a loud cry, Jesus says, "Father, into your hands I commit my spirit!" (quoting Psalm 31:5), and breathes his last.',
gospels:['luke'],
refs:'Luke 23:46',
unique:'Unique to Luke.',
bsbLinks:{ luke:BSB.luke(23) }
},
{
title:'Jesus Breathes His Last & The Temple Curtain Torn',
body:'Jesus utters a loud cry and breathes his last. The curtain of the temple is torn in two, from top to bottom.',
gospels:['matt','mark','luke'],
refs:'Matt 27:5051 · Mark 15:3738 · Luke 23:4546',
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
},
{
title:'Earthquake, Rocks Split & Tombs Opened (Matthew)',
body:'The earth shakes and rocks split. Tombs are opened and many saints who had fallen asleep are raised, appearing to many in Jerusalem after the resurrection.',
gospels:['matt'],
refs:'Matt 27:5153',
unique:'Unique to Matthew — a remarkable eschatological sign accompanying the death of Christ.',
bsbLinks:{ matt:BSB.matt(27) }
},
{
title:"The Centurion's Confession",
body:'The Roman centurion, seeing how Jesus died, declares: "Truly this man was the Son of God!" (Matt/Mark) or "Certainly this man was innocent!" (Luke).',
gospels:['matt','mark','luke'],
refs:'Matt 27:54 · Mark 15:39 · Luke 23:47',
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
},
{
title:'The Women Watching from a Distance',
body:'Many women who followed Jesus from Galilee look on from a distance, including Mary Magdalene, Mary the mother of James and Joseph, and Salome.',
gospels:['matt','mark','luke'],
refs:'Matt 27:5556 · Mark 15:4041 · Luke 23:49',
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
},
{
title:"Soldiers Break No Bones — Spear Thrust (John)",
body:"To hasten death, soldiers break the legs of the two criminals. Finding Jesus already dead, a soldier pierces his side with a spear, and blood and water immediately flow out.",
gospels:['john'],
refs:'John 19:3137',
unique:"Unique to John. He sees this fulfilling 'Not one of his bones will be broken' (Ex 12:46; Ps 34:20) and Zechariah 12:10.",
bsbLinks:{ john:BSB.john(19) }
},
]},
// ── PHASE 5: BURIAL ──────────────────────────────────────────
{ phase:'burial', phaseLabel:'Phase V', phaseTitle:'The Burial of Jesus', events:[
{
title:'Joseph of Arimathea Requests the Body',
body:"Joseph of Arimathea, a respected council member and secret disciple, goes to Pilate and asks for the body of Jesus. Pilate confirms with the centurion that Jesus is dead and grants the request.",
gospels:['matt','mark','luke','john'],
refs:'Matt 27:5758 · Mark 15:4245 · Luke 23:5052 · John 19:38',
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(19) }
},
{
title:'Nicodemus Brings Spices (John)',
body:"Nicodemus, who had come to Jesus by night, brings a mixture of myrrh and aloes — about seventy-five pounds.",
gospels:['john'],
refs:'John 19:39',
unique:'Unique to John. The extravagant amount of spices honors Jesus as royalty.',
bsbLinks:{ john:BSB.john(19) }
},
{
title:'The Body Wrapped & Laid in the Tomb',
body:'The body is wrapped in a clean linen shroud and laid in a new tomb cut from rock, in which no one had yet been buried. A large stone is rolled against the entrance.',
gospels:['matt','mark','luke','john'],
refs:'Matt 27:5960 · Mark 15:46 · Luke 23:53 · John 19:4042',
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(19) }
},
{
title:'The Women See Where He Is Laid',
body:'Mary Magdalene and Mary the mother of Joses observe where Jesus is laid. They return home and prepare spices and ointments.',
gospels:['matt','mark','luke'],
refs:'Matt 27:61 · Mark 15:47 · Luke 23:5556',
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
},
{
title:'The Guard Placed at the Tomb (Matthew)',
body:"The chief priests and Pharisees ask Pilate to secure the tomb, fearing the disciples will steal the body and claim a resurrection. Pilate gives them a guard; they seal the stone.",
gospels:['matt'],
refs:'Matt 27:6266',
unique:'Unique to Matthew — sets up the resurrection narrative in Matthew 28.',
bsbLinks:{ matt:BSB.matt(27) }
},
]},
];
function buildTimeline() {
const container = document.getElementById('timeline');
container.innerHTML = '';
const colorMap = { matt:'var(--matt-color)', mark:'var(--mark-color)', luke:'var(--luke-color)', john:'var(--john-color)' };
data.forEach(phase => {
const phHeader = document.createElement('div');
phHeader.className = 'phase-header';
phHeader.setAttribute('data-phase', phase.phase);
phHeader.innerHTML = `<div class="phase-node"></div><span class="phase-label">${phase.phaseLabel}</span><span class="phase-title">${phase.phaseTitle}</span>`;
container.appendChild(phHeader);
phase.events.forEach(ev => {
const card = document.createElement('div');
card.className = 'event-card';
card.setAttribute('data-gospels', ev.gospels.join(' '));
card.setAttribute('data-phase', phase.phase);
const borderColor = ev.gospels.length === 4 ? 'var(--crimson)' : colorMap[ev.gospels[0]];
card.style.borderLeftColor = borderColor;
const badges = ev.gospels.map(g => {
const url = ev.bsbLinks && ev.bsbLinks[g];
const label = g.charAt(0).toUpperCase() + g.slice(1);
if (url) {
return `<a class="badge badge-${g}" href="${url}" target="_blank" rel="noopener" title="Open ${label} in BSB">${label} ↗</a>`;
}
return `<span class="badge badge-${g}">${label}</span>`;
}).join('');
const uniqueNote = ev.unique ? `<div class="unique-note">✦ ${ev.unique}</div>` : '';
card.innerHTML = `
<div class="event-title">${ev.title}</div>
<div class="event-body">${ev.body}</div>
<div class="gospel-badges">${badges}</div>
<div class="ref-text">${ev.refs}</div>
${uniqueNote}
`;
container.appendChild(card);
});
});
applyFilters();
}
function applyFilters() {
document.querySelectorAll('.event-card').forEach(card => {
const cPhase = card.getAttribute('data-phase');
const cGospels = card.getAttribute('data-gospels').split(' ');
const phaseOk = activePhase === 'all' || cPhase === activePhase;
const gospelOk = cGospels.some(g => activeGospels.has(g));
card.classList.toggle('hidden', !phaseOk || !gospelOk);
});
document.querySelectorAll('.phase-header').forEach(h => {
const hPhase = h.getAttribute('data-phase');
h.style.display = (activePhase === 'all' || hPhase === activePhase) ? '' : 'none';
});
}
function toggleGospel(gospel, el) {
if (activeGospels.has(gospel)) {
if (activeGospels.size === 1) return;
activeGospels.delete(gospel);
el.classList.add('dimmed');
} else {
activeGospels.add(gospel);
el.classList.remove('dimmed');
}
applyFilters();
}
function filterPhase(phase, btn) {
activePhase = phase;
document.querySelectorAll('.filter-btn').forEach(b => {
b.classList.remove('active-filter');
b.style.background = 'transparent';
b.style.color = 'var(--parchment-dark)';
b.style.borderColor = '#555';
});
btn.classList.add('active-filter');
btn.style.background = 'var(--gold)';
btn.style.color = 'var(--ink)';
btn.style.borderColor = 'var(--gold)';
applyFilters();
}
buildTimeline();
</script>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://versebyversewithnate.us/</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/start-here</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/episodes</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/study</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>weekly</changefreq>
<priority>0.85</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/resources</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/questions</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/about</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/contact</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/subscribe</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/privacy</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/terms</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
</urlset>
+186 -47
View File
@@ -1,62 +1,201 @@
import express from 'express' import express from 'express'
import { mkdir, readFile, writeFile } from 'node:fs/promises' import { hasVisitorConsent } from './server/helpers.js'
import path from 'node:path' import { isValidAdminSession } from './server/auth.js'
import { fileURLToPath } from 'node:url' import { BACKUP_INTERVAL_MS } from './server/config.js'
import { state } from './server/state.js'
import {
loadHitStatsFromDisk,
loadVisitorStatsFromDisk,
loadContactSubmissionsFromDisk,
loadReplyTemplatesFromDisk,
loadReplyHistoryFromDisk,
loadQuestionsFromDisk,
loadDraftQuestionsFromDisk,
loadStudyUsersFromDisk,
loadStudyCommunityFromDisk,
loadStudyRemindersFromDisk,
loadStudyCommentsFromDisk,
loadStudyCertificatesFromDisk,
loadEpisodeScriptsFromDisk,
migrateStudyNotesIfNeeded,
loadDownloadCountsFromDisk,
loadQrCodesFromDisk,
loadEpisodePlaysFromDisk,
loadPodcastChecklistFromDisk,
loadAnalyticsEventsFromDisk,
loadEmailSettingsFromDisk,
loadCalendarEventsFromDisk,
loadAuditLogFromDisk,
loadWebhooksFromDisk,
createBackupSnapshot,
refreshContentCaches,
queueHitStatsWrite,
} from './server/data.js'
import { logResendEmailAlignmentWarnings, sendStudyReminderEmail, sendStudyReengagementEmail } from './server/email.js'
import {
detectBot,
sanitizeUserAgent,
shouldCountHit,
recordHit,
scheduleStudyReminders,
scheduleReengagementEmails,
} from './server/study-helpers.js'
import { recordVisitor } from './server/routes/analytics.js'
const __filename = fileURLToPath(import.meta.url) // Route registrars
const __dirname = path.dirname(__filename) import { register as registerAdminAuth } from './server/routes/admin-auth.js'
const DATA_DIR = path.join(__dirname, 'data') import { register as registerAdminContent } from './server/routes/admin-content.js'
const DATA_FILE = path.join(DATA_DIR, 'admin-content.json') import { register as registerAdminAssets } from './server/routes/admin-assets.js'
const DIST_DIR = path.join(__dirname, 'dist') import { register as registerAdminBackup } from './server/routes/admin-backup.js'
const INDEX_FILE = path.join(DIST_DIR, 'index.html') import { register as registerStudyAuth } from './server/routes/study-auth.js'
import { register as registerStudyData } from './server/routes/study-data.js'
import { register as registerStudyAccount } from './server/routes/study-account.js'
import { register as registerStudyComments } from './server/routes/study-comments.js'
import { register as registerStudyCertificate } from './server/routes/study-certificate.js'
import { register as registerEpisodeScripts } from './server/routes/episode-scripts.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 registerQuestions } from './server/routes/questions.js'
import { register as registerAnalytics } from './server/routes/analytics.js'
import { register as registerDownloads } from './server/routes/downloads.js'
import { register as registerEpisodes } from './server/routes/episodes.js'
import { register as registerFeed } from './server/routes/feed.js'
import { register as registerQrCodes } from './server/routes/qr-codes.js'
import { register as registerBiblePassage } from './server/routes/bible-passage.js'
import { register as registerPublic } from './server/routes/public.js'
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)
app.set('trust proxy', Number.isFinite(trustProxyHops) && trustProxyHops >= 0 ? trustProxyHops : 1)
app.get('/api/admin-content', async (_req, res) => { app.use((_req, res, next) => {
try { res.setHeader('X-Content-Type-Options', 'nosniff')
const raw = await readFile(DATA_FILE, 'utf8') res.setHeader('X-Frame-Options', 'SAMEORIGIN')
const parsed = JSON.parse(raw) res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
res.json(parsed) res.setHeader(
} catch { 'Content-Security-Policy',
res.status(404).json({ message: 'No saved admin content file yet.' }) [
} "default-src 'self'",
}) "script-src 'self' 'unsafe-inline' https://www.truthforlife.org https://ajax.googleapis.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
app.put('/api/admin-content', async (req, res) => { "font-src 'self' https://fonts.gstatic.com",
try { "img-src 'self' data: https:",
const { siteContent } = req.body ?? {} "media-src 'self' https:",
"frame-src https:",
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) { "connect-src 'self' https:",
res.status(400).json({ message: 'Invalid payload: siteContent must be an object.' }) ].join('; '),
return
}
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
DATA_FILE,
JSON.stringify({ siteContent, updatedAt: new Date().toISOString() }, null, 2),
'utf8',
) )
next()
res.json({ ok: true })
} catch {
res.status(500).json({ message: 'Failed to persist admin content.' })
}
}) })
app.use(express.static(DIST_DIR)) // Register API routes
registerAdminAuth(app)
registerAdminContent(app)
registerAdminAssets(app)
registerAdminBackup(app)
registerStudyAuth(app)
registerStudyData(app)
registerStudyAccount(app)
registerStudyComments(app)
registerStudyCertificate(app)
registerEpisodeScripts(app)
registerContact(app)
registerCalendarEvents(app)
registerInboundEmail(app)
registerQuestions(app)
registerAnalytics(app)
registerDownloads(app)
registerEpisodes(app)
registerFeed(app)
registerQrCodes(app)
registerBiblePassage(app)
app.use(async (_req, res) => { // Hit-counting middleware (must come before public routes)
try { app.use((req, res, next) => {
const html = await readFile(INDEX_FILE, 'utf8') if (shouldCountHit(req)) {
res.type('html').send(html) const ua = sanitizeUserAgent(req.get('user-agent'))
} catch { const botDetection = detectBot(ua)
res.status(503).send('Frontend build not found. Run "npm run build" first.') recordHit(req.path, botDetection.isBot, botDetection.reason)
queueHitStatsWrite()
if (hasVisitorConsent(req) && !botDetection.isBot && !isValidAdminSession(req)) {
recordVisitor(req, res).catch(err => {
console.error('[visitor-stats] failed to record visitor:', err)
})
} }
}
next()
}) })
// Public routes (robots, sitemap, static, SPA fallback)
registerPublic(app)
const PORT = Number(process.env.PORT ?? 4173) const PORT = Number(process.env.PORT ?? 4173)
app.listen(PORT, () => { Promise.all([
loadHitStatsFromDisk(),
loadVisitorStatsFromDisk(),
loadContactSubmissionsFromDisk(),
loadReplyTemplatesFromDisk(),
loadReplyHistoryFromDisk(),
loadQuestionsFromDisk(),
loadDraftQuestionsFromDisk(),
loadStudyUsersFromDisk(),
loadStudyCommunityFromDisk(),
loadStudyRemindersFromDisk(),
loadStudyCommentsFromDisk(),
loadStudyCertificatesFromDisk(),
loadEpisodeScriptsFromDisk(),
migrateStudyNotesIfNeeded(),
loadDownloadCountsFromDisk(),
loadQrCodesFromDisk(),
loadEpisodePlaysFromDisk(),
loadPodcastChecklistFromDisk(),
loadAnalyticsEventsFromDisk(),
loadEmailSettingsFromDisk(),
loadCalendarEventsFromDisk(),
loadAuditLogFromDisk(),
loadWebhooksFromDisk(),
refreshContentCaches(),
])
.catch(err => {
console.error('[stats] failed to load persisted stats:', err)
})
.finally(() => {
createBackupSnapshot('startup').catch(() => {})
setInterval(() => {
createBackupSnapshot('scheduled').catch(() => {})
}, BACKUP_INTERVAL_MS)
startReminderScheduler()
// Purge expired study sessions every hour
setInterval(() => {
const now = Date.now()
for (const [token, session] of state.studySessions) {
if (session.expiresAt <= now) state.studySessions.delete(token)
}
}, 60 * 60 * 1000)
// Send study reminder emails for newly released lessons every hour
setInterval(() => {
scheduleStudyReminders(sendStudyReminderEmail).catch(err => {
console.error('[study-reminders] failed to schedule reminders:', err)
})
}, 60 * 60 * 1000)
void scheduleStudyReminders(sendStudyReminderEmail)
// Send re-engagement emails to inactive study users every hour
setInterval(() => {
scheduleReengagementEmails(sendStudyReengagementEmail).catch(err => {
console.error('[study-reengagement] failed to schedule emails:', err)
})
}, 60 * 60 * 1000)
void scheduleReengagementEmails(sendStudyReengagementEmail)
app.listen(PORT, () => {
logResendEmailAlignmentWarnings()
console.log(`Portfolio app listening on http://localhost:${PORT}`) console.log(`Portfolio app listening on http://localhost:${PORT}`)
}) })
})
+274
View File
@@ -0,0 +1,274 @@
import { createHash, randomUUID, timingSafeEqual, createHmac, randomFillSync } from 'node:crypto'
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { parseCookies, cookieSecureFlag } from './helpers.js'
import { DATA_DIR } from './paths.js'
const TOTP_SECRET_FILE = path.join(DATA_DIR, 'totp-secret.json')
// Pending sessions: password verified, waiting for TOTP code
// Map<pendingToken, { expiresAt }>
const TOTP_PENDING_TTL_MS = 5 * 60 * 1000
const totpPendingSessions = new Map()
const ADMIN_SESSION_COOKIE = 'vbn_admin_session'
const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
const ADMIN_SESSION_ABSOLUTE_TTL_MS = 30 * 24 * 60 * 60 * 1000
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
const adminSessions = new Map()
function cookieFlags() {
return cookieSecureFlag()
}
export function sha256(value) {
return createHash('sha256').update(String(value)).digest('hex')
}
export function isAdminPasswordConfigured() {
return Boolean(ADMIN_PASSWORD)
}
export function validateAdminPasswordSetup() {
if (!isAdminPasswordConfigured() && process.env.NODE_ENV === 'production') {
throw new Error('ADMIN_PASSWORD is required in production.')
}
if (!isAdminPasswordConfigured()) {
console.warn('ADMIN_PASSWORD is not configured; admin routes will remain disabled until the environment is configured.')
}
}
export function isAdminPasswordValid(password) {
if (!isAdminPasswordConfigured()) return false
const a = Buffer.from(sha256(password), 'utf8')
const b = Buffer.from(sha256(ADMIN_PASSWORD), 'utf8')
if (a.length !== b.length) return false
return timingSafeEqual(a, b)
}
// ── TOTP (RFC 6238) — implemented with Node built-in crypto ─────────────────
const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
function base32Decode(str) {
const s = str.toUpperCase().replace(/=+$/, '')
let bits = 0
let value = 0
const output = []
for (const char of s) {
const idx = BASE32_CHARS.indexOf(char)
if (idx === -1) continue
value = (value << 5) | idx
bits += 5
if (bits >= 8) {
output.push((value >>> (bits - 8)) & 0xff)
bits -= 8
}
}
return Buffer.from(output)
}
function base32Encode(buf) {
let bits = 0
let value = 0
let output = ''
for (const byte of buf) {
value = (value << 8) | byte
bits += 8
while (bits >= 5) {
output += BASE32_CHARS[(value >>> (bits - 5)) & 0x1f]
bits -= 5
}
}
if (bits > 0) output += BASE32_CHARS[(value << (5 - bits)) & 0x1f]
return output
}
function totpToken(secret, counter) {
const key = base32Decode(secret)
const msg = Buffer.alloc(8)
// Write 64-bit big-endian counter
const hi = Math.floor(counter / 0x100000000)
const lo = counter >>> 0
msg.writeUInt32BE(hi, 0)
msg.writeUInt32BE(lo, 4)
const hmac = createHmac('sha1', key).update(msg).digest()
const offset = hmac[hmac.length - 1] & 0x0f
const code = ((hmac[offset] & 0x7f) << 24)
| (hmac[offset + 1] << 16)
| (hmac[offset + 2] << 8)
| hmac[offset + 3]
return String(code % 1000000).padStart(6, '0')
}
export function generateTotpSecret() {
const buf = Buffer.allocUnsafe(20)
randomFillSync(buf)
return base32Encode(buf)
}
export async function loadTotpState() {
try {
const raw = await readFile(TOTP_SECRET_FILE, 'utf8')
return JSON.parse(raw)
} catch {
return null
}
}
export async function saveTotpState(state) {
await writeFile(TOTP_SECRET_FILE, JSON.stringify(state, null, 2), 'utf8')
}
export async function isTotpEnabled() {
const state = await loadTotpState()
return Boolean(state?.secret && state?.verified)
}
function randomBytesForRecovery(n) {
const buf = Buffer.allocUnsafe(n)
randomFillSync(buf)
return buf
}
export function getTotpUri(secret, label = 'Siteforge Admin') {
const issuer = 'Siteforge'
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(label)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`
}
export function verifyTotpCode(secret, code) {
try {
const token = String(code).replace(/\s/g, '')
const step = Math.floor(Date.now() / 1000 / 30)
// Accept current step and one step either side (±30 seconds clock skew)
for (const offset of [-1, 0, 1]) {
if (totpToken(secret, step + offset) === token) return true
}
return false
} catch {
return false
}
}
// ── Recovery Codes ──────────────────────────────────────────────────────────
const RECOVERY_CODE_COUNT = 8
function generateRecoveryCode() {
// Format: XXXX-XXXX-XXXX (uppercase alphanumeric, no ambiguous chars)
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const randBytes = randomBytesForRecovery(12)
let code = ''
for (let i = 0; i < 12; i++) {
if (i > 0 && i % 4 === 0) code += '-'
code += chars[randBytes[i] % chars.length]
}
return code
}
export function generateRecoveryCodes() {
const codes = []
for (let i = 0; i < RECOVERY_CODE_COUNT; i++) {
codes.push(generateRecoveryCode())
}
return codes
}
export function hashRecoveryCode(code) {
return sha256(code.replace(/-/g, '').toUpperCase())
}
// Returns the matched code if valid, null otherwise. Mutates state.hashedRecoveryCodes.
export function consumeRecoveryCode(state, inputCode) {
if (!Array.isArray(state.hashedRecoveryCodes) || state.hashedRecoveryCodes.length === 0) return false
const normalized = inputCode.replace(/[-\s]/g, '').toUpperCase()
const inputHash = sha256(normalized)
const idx = state.hashedRecoveryCodes.findIndex(h => {
const a = Buffer.from(h, 'utf8')
const b = Buffer.from(inputHash, 'utf8')
return a.length === b.length && timingSafeEqual(a, b)
})
if (idx === -1) return false
state.hashedRecoveryCodes.splice(idx, 1)
return true
}
// ── Pending (password-ok, awaiting TOTP) sessions ───────────────────────────
export function createPendingSession() {
const token = randomUUID()
totpPendingSessions.set(token, { expiresAt: Date.now() + TOTP_PENDING_TTL_MS })
return token
}
export function consumePendingSession(token) {
if (!token) return false
const entry = totpPendingSessions.get(token)
if (!entry || entry.expiresAt <= Date.now()) {
totpPendingSessions.delete(token)
return false
}
totpPendingSessions.delete(token)
return true
}
// ── Admin Sessions ───────────────────────────────────────────────────────────
export function createAdminSession() {
const token = randomUUID()
const now = Date.now()
adminSessions.set(token, { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt: now + ADMIN_SESSION_ABSOLUTE_TTL_MS })
return token
}
export function deleteAdminSession(token) {
if (token) {
adminSessions.delete(token)
}
}
export function isValidAdminSession(req) {
if (!isAdminPasswordConfigured()) return false
const cookies = parseCookies(req.headers.cookie)
const sessionToken = cookies[ADMIN_SESSION_COOKIE]
if (!sessionToken) return false
const now = Date.now()
const session = adminSessions.get(sessionToken)
if (!session) return false
// Support legacy sessions stored as a plain number (expiresAt)
const expiresAt = typeof session === 'object' ? session.expiresAt : session
const absoluteExpiresAt = typeof session === 'object' ? session.absoluteExpiresAt : Infinity
if (expiresAt <= now || absoluteExpiresAt <= now) {
adminSessions.delete(sessionToken)
return false
}
adminSessions.set(sessionToken, { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt })
return true
}
export function setAdminSessionCookie(res, token) {
res.append(
'Set-Cookie',
`${ADMIN_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
)
}
export function clearAdminSessionCookie(res) {
res.append(
'Set-Cookie',
`${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
)
}
export function requireAdminAuth(req, res, next) {
if (!isValidAdminSession(req)) {
res.status(401).json({ message: 'Unauthorized' })
return
}
next()
}
+252
View File
@@ -0,0 +1,252 @@
import path from 'node:path'
import { readFileSync } from 'node:fs'
import { execSync } from 'node:child_process'
import { validateAdminPasswordSetup } from './auth.js'
import { ROOT_DIR, DATA_DIR } from './paths.js'
export { ROOT_DIR, DATA_DIR }
function readPackageVersion() {
try {
const pkg = JSON.parse(readFileSync(path.join(ROOT_DIR, 'package.json'), 'utf8'))
return typeof pkg.version === 'string' && pkg.version.trim() ? pkg.version.trim() : 'unknown'
} catch {
return 'unknown'
}
}
function readGitCommit() {
// CI injects the full SHA as COMMIT_SHA; fall back to running git locally.
if (typeof process.env.COMMIT_SHA === 'string' && process.env.COMMIT_SHA.trim()) {
return process.env.COMMIT_SHA.trim().slice(0, 7)
}
try {
return execSync('git rev-parse --short HEAD', { cwd: ROOT_DIR, timeout: 2000 }).toString().trim()
} catch {
return 'unknown'
}
}
// APP_VERSION is sourced from package.json.
// GIT_COMMIT is the 7-char short SHA, from COMMIT_SHA env var (set by CI) or git at startup.
export const APP_VERSION = readPackageVersion()
export const GIT_COMMIT = readGitCommit()
export const DATA_FILE = path.join(DATA_DIR, 'admin-content.json')
export const DRAFT_DATA_FILE = path.join(DATA_DIR, 'admin-content-draft.json')
export const HIT_STATS_FILE = path.join(DATA_DIR, 'hit-stats.json')
export const VISITOR_STATS_FILE = path.join(DATA_DIR, 'visitor-stats.json')
export const CONTACT_SUBMISSIONS_FILE = path.join(DATA_DIR, 'contact-submissions.json')
export const QUESTIONS_FILE = path.join(DATA_DIR, 'questions.json')
export const DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.json')
export const STUDY_USERS_FILE = path.join(DATA_DIR, 'study-users.json')
export const STUDY_NOTES_FILE = path.join(DATA_DIR, 'study-notes.json') // legacy — kept only for one-time migration
export const STUDY_NOTES_DIR = path.join(DATA_DIR, 'study-notes')
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 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 PODCAST_CHECKLIST_FILE = path.join(DATA_DIR, 'podcast-checklist.json')
export const BACKUP_DIR = path.join(DATA_DIR, 'backups')
export const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
export const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json')
export const DOWNLOAD_COUNTS_FILE = path.join(DATA_DIR, 'download-counts.json')
export const STUDY_REMINDERS_FILE = path.join(DATA_DIR, 'study-reminders.json')
export const STUDY_COMMENTS_FILE = path.join(DATA_DIR, 'study-section-comments.json')
export const STUDY_CERTIFICATES_FILE = path.join(DATA_DIR, 'study-certificates.json')
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 EPISODE_PLAYS_FILE = path.join(DATA_DIR, 'episode-plays.json')
export const ANALYTICS_EVENTS_FILE = path.join(DATA_DIR, 'analytics-events.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 WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.json')
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
export const DIST_DIR = path.join(ROOT_DIR, 'dist')
export const INDEX_FILE = path.join(DIST_DIR, 'index.html')
export const DIST_IMAGES_DIR = path.join(DIST_DIR, 'images')
export const PUBLIC_IMAGES_DIR = path.join(ROOT_DIR, 'public', 'images')
validateAdminPasswordSetup()
export const TITUS_STUDY_FILE = process.env.TITUS_STUDY_FILE
? path.resolve(ROOT_DIR, process.env.TITUS_STUDY_FILE)
: path.join(ROOT_DIR, 'A_Study_of_Titus.pdf')
export const TITUS_STUDY_DOWNLOAD_NAME = process.env.TITUS_STUDY_DOWNLOAD_NAME ?? 'A_Study_of_Titus.pdf'
// Rate limit / timing constants
export const VISITOR_COOKIE = 'vbn_vid'
export const CONSENT_COOKIE = 'vbn_analytics_consent'
export const MAX_RECENT_VISITS = 1000
export const VISITOR_RETENTION_DAYS_DEFAULT = 180
export const BACKUP_RETENTION_DAYS = 30
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 CONTACT_EMAIL_COOLDOWN_MS = Math.max(10 * 1000, Number(process.env.CONTACT_EMAIL_COOLDOWN_MS ?? 60 * 1000) || 60 * 1000)
export const DOWNLOAD_TOKEN_TTL_MS = 10 * 60 * 1000
export const MAX_QUESTIONS = 1000
export const MAX_STUDY_COMMENTS = 10000
export const MAX_STUDY_COMMENT_LENGTH = 2000
export const MAX_STUDY_USERS = 5000
export const MAX_STUDY_ENROLLMENTS_PER_USER = 100
export const MAX_STUDY_NOTES_PER_USER = 500
export const MAX_STUDY_NOTE_LENGTH = 12000
export const EMAIL_CHANGE_TOKEN_TTL_MS = 24 * 60 * 60 * 1000
export const STUDY_SESSION_COOKIE = 'vbn_study_session'
export const STUDY_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000
export const STUDY_TOTP_PENDING_TTL_MS = 5 * 60 * 1000
export const EMAIL_OTP_TTL_MS = 10 * 60 * 1000
export const EMAIL_OTP_MAX_ATTEMPTS = 5
export const USE_RESEND_AUTOMATION_WELCOME = process.env.RESEND_AUTOMATION_WELCOME === 'true'
export const DEFAULT_RESEND_FROM = 'Verse by Verse with Nate <hello@versebyversewithnate.us>'
export const DEFAULT_RESEND_TO = 'hello@versebyversewithnate.us'
export const DEFAULT_RESEND_REPLY_TO = 'hello@versebyversewithnate.us'
export const ADMIN_REPLY_FROM = DEFAULT_RESEND_FROM
export const NATE_RESEND_FROM = 'Verse by Verse with Nate <nate@versebyversewithnate.us>'
// 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 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 = [
{
id: 'thanks-for-reaching-out',
label: 'Thank You Reply',
subject: 'Thanks for reaching out to Verse by Verse with Nate',
message: 'Thank you for reaching out.\n\nI appreciate your message and wanted to follow up personally.',
},
{
id: 'question-received',
label: 'Question Received',
subject: 'Your Bible question was received',
message: 'Thank you for sending your Bible question.\n\nI have received it, and I appreciate you taking the time to write in.',
},
{
id: 'testimony-thank-you',
label: 'Testimony Thank You',
subject: 'Thank you for sharing your testimony',
message: 'Thank you for sharing what the Lord is doing in your life.\n\nYour message was an encouragement to read.',
},
]
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 = {
totalHits: 0,
realHits: 0,
botHits: 0,
firstHitAt: null,
lastHitAt: null,
byPath: {},
byPathReal: {},
byPathBot: {},
byDay: {},
byDayReal: {},
byDayBot: {},
botReasons: {},
}
export const EMPTY_VISITOR_STATS = {
totalVisits: 0,
uniqueVisitors: 0,
returningVisits: 0,
firstVisitAt: null,
lastVisitAt: null,
visitors: {},
ipHashIndex: {},
recentVisits: [],
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],
}
}
+1557
View File
File diff suppressed because it is too large Load Diff
+637
View File
@@ -0,0 +1,637 @@
import { Resend } from 'resend'
import { escapeHtml, buildAbsoluteUrl, splitName } from './helpers.js'
import {
DEFAULT_RESEND_FROM,
DEFAULT_SEO,
} from './config.js'
import { state } from './state.js'
// ── Address helpers ────────────────────────────────────────────────────────
export function getResendFromAddress() {
return process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
}
export function getResendReplyToAddress() {
return process.env.RESEND_REPLY_TO ?? 'hello@versebyversewithnate.us'
}
export function getResendInboxAddress() {
return process.env.RESEND_TO ?? 'hello@versebyversewithnate.us'
}
export function getCanonicalBaseUrl() {
const configured = state.cachedSiteContent?.seo?.canonicalUrl ?? DEFAULT_SEO.canonicalUrl
if (typeof configured !== 'string' || !configured.trim()) return DEFAULT_SEO.canonicalUrl
return configured.trim()
}
export function getAddressDomain(addressValue) {
const raw = String(addressValue || '').trim()
if (!raw) return ''
const candidate = raw.includes('<') && raw.includes('>')
? raw.slice(raw.lastIndexOf('<') + 1, raw.lastIndexOf('>')).trim()
: raw
const at = candidate.lastIndexOf('@')
if (at <= 0 || at === candidate.length - 1) return ''
return candidate.slice(at + 1).toLowerCase()
}
export function logResendEmailAlignmentWarnings() {
const warnings = []
const fromAddress = getResendFromAddress()
const replyToAddress = getResendReplyToAddress()
const fromDomain = getAddressDomain(fromAddress)
const replyDomain = getAddressDomain(replyToAddress)
const hasApiKey = Boolean(process.env.RESEND_API_KEY)
if (!hasApiKey) warnings.push('RESEND_API_KEY is missing. Contact and reply emails cannot send.')
if (!fromDomain) warnings.push('RESEND_FROM is missing or malformed. Use a verified domain sender identity.')
if (fromDomain.endsWith('resend.dev')) warnings.push('RESEND_FROM uses resend.dev. Move to your own verified domain for best deliverability.')
if (fromDomain && replyDomain && fromDomain !== replyDomain) warnings.push('RESEND_FROM and RESEND_REPLY_TO use different domains. This can weaken alignment.')
if (!process.env.RESEND_WEBHOOK_TOKEN) warnings.push('RESEND_WEBHOOK_TOKEN is not set. Delivery webhooks are not authenticated.')
if (hasApiKey) warnings.push('Verify SPF, DKIM, and DMARC for the sender domain to improve inbox placement.')
if (warnings.length > 0) {
console.warn('[email-health] Resend alignment checks:')
for (const warning of warnings) {
console.warn(`[email-health] - ${warning}`)
}
}
}
// ── Send helpers ───────────────────────────────────────────────────────────
export async function sendResendEmailWithRetry({ resend, payload, context, maxAttempts = 2 }) {
let lastError = null
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const result = await resend.emails.send(payload)
if (!result?.error) return result
lastError = result.error
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 250 * attempt))
}
} catch (err) {
lastError = err
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 250 * attempt))
}
}
}
throw lastError ?? new Error(`[${context}] email send failed`)
}
export async function syncContactToResend(name, email) {
if (!process.env.RESEND_API_KEY) return
const { firstName, lastName } = splitName(name)
const contactResend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
try {
const { error: contactError } = await contactResend.contacts.create({
email,
firstName,
lastName,
unsubscribed: false,
...(process.env.RESEND_SEGMENT_ID ? { segments: [{ id: process.env.RESEND_SEGMENT_ID }] } : {}),
})
if (contactError) {
const { error: updateError } = await contactResend.contacts.update({ email, firstName, lastName, unsubscribed: false })
if (updateError) console.error('[resend] contact sync error:', updateError)
}
} catch (err) {
console.error('[resend] contact sync exception:', err)
}
}
// ── Email HTML builders ────────────────────────────────────────────────────
export function buildBrandedEmailHtml({ title, eyebrow, bodyHtml, ctaLabel, ctaUrl, footerHtml }) {
const ctaBlock = ctaLabel && ctaUrl
? `<p style="margin:24px 0 0;"><a href="${escapeHtml(ctaUrl)}" target="_blank" style="display:inline-block;padding:12px 22px;background:#c9a84c;border:1px solid #e0c070;border-radius:999px;color:#111111;font-family:Georgia,serif;font-size:13px;font-weight:700;letter-spacing:0.14em;text-decoration:none;text-transform:uppercase;">${escapeHtml(ctaLabel)}</a></p>`
: ''
return (
`<div style="margin:0;padding:0;background-color:#0a0a08;font-family:Georgia,serif;">` +
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#0a0a08;">` +
`<tr><td align="center" style="padding:40px 20px;">` +
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:580px;margin:0 auto;background-color:#0f0f0c;border:1px solid #2a2518;">` +
`<tr><td align="center" style="background-color:#0d0d0a;padding:36px 40px 28px;border-bottom:1px solid #2a2518;">` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">${escapeHtml(eyebrow ?? 'Verse by Verse with Nate')}</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:28px;font-weight:600;color:#e0c070;line-height:1.2;">${escapeHtml(title)}</p>` +
`</td></tr>` +
`<tr><td style="padding:40px 40px 0;">` +
`<div style="font-family:Georgia,serif;font-size:15px;line-height:1.8;color:#c8c0ac;">${bodyHtml}</div>` +
`${ctaBlock}` +
`</td></tr>` +
`<tr><td style="padding:28px 40px 40px;text-align:center;">${footerHtml ?? ''}</td></tr>` +
`</table>` +
`</td></tr></table>` +
`</div>`
)
}
export function buildContactWelcomeEmailTemplate({
greetingName,
welcomeIntro,
welcomeCurrentSeries,
welcomeStartHereTitle,
welcomeStartHereSummary,
welcomeExpect1,
welcomeExpect2,
welcomeExpect3,
welcomeScripture,
welcomeScriptureRef,
welcomeSignoff,
welcomeGreetingPrefix = "Glad you're here",
welcomeSpotifyUrl,
welcomeAppleUrl,
welcomeAmazonUrl,
welcomeWebsiteUrl,
welcomeEpisodeUrl,
welcomeImageUrl,
welcomeSpotifyBtnLabel = 'Listen on Spotify',
welcomeAppleBtnLabel = 'Apple Podcasts',
welcomeStartHereLinkLabel = 'Open Start Here page',
}) {
const heading = greetingName
? `${escapeHtml(welcomeGreetingPrefix)}, ${escapeHtml(greetingName)}.`
: `${escapeHtml(welcomeGreetingPrefix)}.`
return {
text:
`Welcome to Verse by Verse with Nate!\n\n` +
`${heading}\n\n` +
`${welcomeIntro}\n\n` +
`${welcomeCurrentSeries}\n\n` +
`Start here: ${welcomeEpisodeUrl}\n` +
`${welcomeStartHereTitle}\n` +
`${welcomeStartHereSummary}\n` +
`${welcomeSpotifyBtnLabel}: ${welcomeSpotifyUrl}\n` +
`${welcomeAppleBtnLabel}: ${welcomeAppleUrl}\n` +
`Amazon Music: ${welcomeAmazonUrl}\n` +
`Website: ${welcomeWebsiteUrl}\n\n` +
`What to expect:\n` +
`- ${welcomeExpect1}\n` +
`- ${welcomeExpect2}\n` +
`- ${welcomeExpect3}\n\n` +
`"${welcomeScripture}"\n${welcomeScriptureRef}\n\n` +
`${welcomeSignoff}`,
html: `<html dir="ltr" lang="en">
<head></head>
<body style="background-color:#ffffff">
<table border="0" width="100%" cellpadding="0" cellspacing="0" role="presentation" align="center">
<tbody>
<tr>
<td style="background-color:#ffffff">
<table align="left" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width:600px;align:left;width:100%;color:#000000;background-color:#ffffff;border-radius:0px;border-color:#000000">
<tbody>
<tr style="width:100%">
<td style="padding:0">
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background-color:#0a0a08">
<tbody>
<tr>
<td align="center" style="padding:40px 20px">
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin:0 auto;max-width:580px;background-color:#0f0f0c;border:1px solid #2a2518">
<tbody>
<tr>
<td align="center" style="padding:36px 40px 28px;background-color:#0d0d0a;border-bottom:1px solid #2a2518">
<img alt="Verse by Verse with Nate" src="${escapeHtml(welcomeImageUrl)}" style="display:block;outline:none;border:2px solid #2a2518;text-decoration:none;max-width:100%;margin:0 auto 20px;border-radius:12px;height:auto" width="468" />
<p style="margin:0 0 6px;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:22px;font-weight:600;color:#c9a84c;letter-spacing:0.04em">Verse by Verse with Nate</p>
<p style="margin:0;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:14px;font-weight:300;color:#7a7060;letter-spacing:0.08em;text-transform:uppercase">Verse by verse. Nugget by nugget.</p>
</td>
</tr>
<tr>
<td style="padding:40px 40px 0">
<p style="margin:0 0 8px;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase">Welcome</p>
<h1 style="margin:0 0 20px;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:30px;font-weight:600;color:#f0ead8;line-height:1.2">${heading}</h1>
<div style="width:40px;height:2px;background-color:#c9a84c;margin-bottom:28px"></div>
</td>
</tr>
<tr>
<td style="padding:0 40px 32px">
<p style="margin:0 0 18px;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75">${escapeHtml(welcomeIntro)}</p>
<p style="margin:0 0 18px;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75">${escapeHtml(welcomeCurrentSeries)}</p>
<p style="margin:0;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75">If you&#8217;re just joining us, the best place to start is Episode 1. It sets the table for everything that follows.</p>
</td>
</tr>
<tr><td style="padding:0 40px"><div style="height:1px;background-color:#2a2518;margin-bottom:32px"></div></td></tr>
<tr>
<td style="padding:0 40px 32px">
<p style="margin:0 0 6px;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase">Start here</p>
<p style="margin:0 0 6px;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:22px;font-weight:600;color:#f0ead8">${escapeHtml(welcomeStartHereTitle)}</p>
<p style="margin:0 0 20px;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:16px;font-weight:300;color:#7a7060;line-height:1.6">${escapeHtml(welcomeStartHereSummary)}</p>
<table border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td style="padding-right:12px">
<a href="${escapeHtml(welcomeSpotifyUrl)}" rel="noopener noreferrer nofollow" style="color:#0d0d0a;text-decoration:none;display:inline-block;padding:11px 22px;background-color:#c9a84c;font-family:'Cormorant Garamond',Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;border-radius:3px" target="_blank">${escapeHtml(welcomeSpotifyBtnLabel)}</a>
</td>
<td>
<a href="${escapeHtml(welcomeAppleUrl)}" rel="noopener noreferrer nofollow" style="color:#c9a84c;text-decoration:none;display:inline-block;padding:11px 22px;background-color:transparent;font-family:'Cormorant Garamond',Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;border-radius:3px;border:1px solid #c9a84c" target="_blank">${escapeHtml(welcomeAppleBtnLabel)}</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr><td style="padding:0 40px"><div style="height:1px;background-color:#2a2518;margin-bottom:32px"></div></td></tr>
<tr>
<td style="padding:0 40px 32px">
<p style="margin:0 0 20px;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase">What to expect</p>
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin-bottom:18px">
<tbody><tr>
<td style="width:28px;vertical-align:top;padding-top:3px"><div style="width:6px;height:6px;background-color:#c9a84c;border-radius:50%;margin-top:7px"></div></td>
<td><p style="margin:0;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65">${escapeHtml(welcomeExpect1)}</p></td>
</tr></tbody>
</table>
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin-bottom:18px">
<tbody><tr>
<td style="width:28px;vertical-align:top;padding-top:3px"><div style="width:6px;height:6px;background-color:#c9a84c;border-radius:50%;margin-top:7px"></div></td>
<td><p style="margin:0;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65">${escapeHtml(welcomeExpect2)}</p></td>
</tr></tbody>
</table>
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody><tr>
<td style="width:28px;vertical-align:top;padding-top:3px"><div style="width:6px;height:6px;background-color:#c9a84c;border-radius:50%;margin-top:7px"></div></td>
<td><p style="margin:0;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65">${escapeHtml(welcomeExpect3)}</p></td>
</tr></tbody>
</table>
</td>
</tr>
<tr><td style="padding:0 40px"><div style="height:1px;background-color:#2a2518;margin-bottom:32px"></div></td></tr>
<tr>
<td style="padding:0 40px 40px">
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-left:2px solid #c9a84c">
<tbody><tr>
<td style="padding:4px 0 4px 20px">
<p style="margin:0 0 8px;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:19px;font-style:italic;font-weight:400;color:#e0c070;line-height:1.6">&#8220;${escapeHtml(welcomeScripture)}&#8221;</p>
<p style="margin:0;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;text-transform:uppercase">${escapeHtml(welcomeScriptureRef)}</p>
</td>
</tr></tbody>
</table>
</td>
</tr>
<tr>
<td align="center" style="padding:28px 40px;background-color:#0a0a08;border-top:1px solid #2a2518;text-align:center">
<p style="margin:0 0 14px;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em">Find the podcast on</p>
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin:0 auto 24px">
<tbody><tr>
<td style="padding:0 10px"><a href="${escapeHtml(welcomeSpotifyUrl)}" rel="noopener noreferrer nofollow" style="color:#7a7060;text-decoration:none;font-family:'Crimson Pro',Georgia,serif;font-size:13px;font-weight:300;letter-spacing:0.04em" target="_blank">Spotify</a></td>
<td style="color:#7a7060;font-size:12px">&#183;</td>
<td style="padding:0 10px"><a href="${escapeHtml(welcomeAppleUrl)}" rel="noopener noreferrer nofollow" style="color:#7a7060;text-decoration:none;font-family:'Crimson Pro',Georgia,serif;font-size:13px;font-weight:300;letter-spacing:0.04em" target="_blank">Apple Podcasts</a></td>
<td style="color:#7a7060;font-size:12px">&#183;</td>
<td style="padding:0 10px"><a href="${escapeHtml(welcomeAmazonUrl)}" rel="noopener noreferrer nofollow" style="color:#7a7060;text-decoration:none;font-family:'Crimson Pro',Georgia,serif;font-size:13px;font-weight:300;letter-spacing:0.04em" target="_blank">Amazon Music</a></td>
<td style="color:#7a7060;font-size:12px">&#183;</td>
<td style="padding:0 10px"><a href="${escapeHtml(welcomeWebsiteUrl)}" rel="noopener noreferrer nofollow" style="color:#7a7060;text-decoration:none;font-family:'Crimson Pro',Georgia,serif;font-size:13px;font-weight:300;letter-spacing:0.04em" target="_blank">Website</a></td>
</tr></tbody>
</table>
<p style="margin:0 0 6px;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:12px;font-weight:300;color:#6b6044;line-height:1.6">You&#8217;re receiving this because you subscribed to <strong>Verse by Verse with Nate</strong>.</p>
<p style="margin:0 0 18px;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:12px;font-weight:300;color:#6b6044"><a href="{{{RESEND_UNSUBSCRIBE_URL}}}" rel="noopener noreferrer nofollow" style="color:#6b6044;text-decoration:underline" target="_blank">Unsubscribe</a></p>
<p style="margin:0;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:13px;font-style:italic;font-weight:400;color:#6b6044">&#8220;Your word is a lamp to my feet and a light to my path.&#8221; &#8212; Psalm 119:105</p>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>`,
}
}
export function buildContactAdminNotificationTemplate({
normalizedMessageType,
trimmedName,
trimmedEmail,
submittedAt,
trimmedMessage,
}) {
return {
subject: `Verse by Verse contact (${normalizedMessageType}): ${trimmedName}`,
text:
`New contact form submission\n\n` +
`Message Type: ${normalizedMessageType}\n` +
`Name: ${trimmedName}\n` +
`Email: ${trimmedEmail}\n` +
`Submitted: ${submittedAt}\n\n` +
`Message:\n${trimmedMessage}`,
html:
`<div style="background:#f5f1e8;padding:24px;font-family:Georgia,serif;color:#201a10;">` +
`<div style="max-width:680px;margin:0 auto;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">` +
`<div style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">` +
`<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>` +
`<h1 style="margin:10px 0 0;color:#f4ead5;font-size:28px;line-height:1.2;">New Contact Form Submission</h1>` +
`</div>` +
`<div style="padding:24px;">` +
`<p style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.6;color:#57452b;">A new message was sent from the website contact form. Reply directly to this email to respond to <strong>${escapeHtml(trimmedName)}</strong>.</p>` +
`<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin-bottom:20px;">` +
`<tr><td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Type</td><td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(normalizedMessageType)}</td></tr>` +
`<tr><td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Name</td><td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(trimmedName)}</td></tr>` +
`<tr><td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Email</td><td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;"><a href="mailto:${escapeHtml(trimmedEmail)}" style="color:#8f5f05;text-decoration:none;">${escapeHtml(trimmedEmail)}</a></td></tr>` +
`<tr><td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;">Submitted</td><td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;">${escapeHtml(submittedAt)}</td></tr>` +
`</table>` +
`<div style="background:#fbf7ef;border:1px solid #efe4cc;border-radius:12px;padding:18px 20px;">` +
`<div style="margin:0 0 10px;font-family:Arial,sans-serif;font-size:13px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#8a6d35;">Message</div>` +
`<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;white-space:pre-wrap;">${escapeHtml(trimmedMessage)}</div>` +
`</div>` +
`</div>` +
`</div>` +
`</div>`,
}
}
export function buildAdminReplyTemplate({ recipientName, message, signature }) {
const safeRecipientName = escapeHtml(recipientName || 'friend')
const safeMessage = escapeHtml(message).replace(/\n/g, '<br/>')
const sig = typeof signature === 'string' && signature.trim() ? signature.trim() : 'Grace and peace,\nVerse by Verse with Nate'
const safeSig = escapeHtml(sig).replace(/\n/g, '<br/>')
return `
<div style="margin:0;padding:0;background-color:#f5f1e8;font-family:Georgia,serif;color:#201a10;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#f5f1e8;">
<tr>
<td align="center" style="padding:28px 16px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:680px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
<tr>
<td style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">
<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>
<h1 style="margin:10px 0 0;color:#f4ead5;font-size:26px;line-height:1.2;">A Personal Reply</h1>
</td>
</tr>
<tr>
<td style="padding:26px 24px 18px;">
<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:16px;line-height:1.6;color:#201a10;">Hi ${safeRecipientName},</p>
<div style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeMessage}</div>
<p style="margin:0;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeSig}</p>
</td>
</tr>
<tr>
<td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:14px 24px;">
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.5;color:#735a2b;">From: hello@versebyversewithnate.us</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
`
}
export function buildQuestionAnsweredEmailTemplate({ firstName, question, answer, questionUrl }) {
const cfg = state.cachedSiteContent ?? {}
const subject = cfg.qaAnsweredEmailSubject?.trim() || 'Your question has been answered — Verse by Verse with Nate'
const bodyText = cfg.qaAnsweredEmailBody?.trim() || 'Nate has answered your question on Verse by Verse with Nate.'
const ctaLabel = cfg.qaAnsweredEmailCtaLabel?.trim() || 'Read Full Answer →'
const signoff = cfg.qaAnsweredEmailSignoff?.trim() || 'Grace and peace,\nNate'
const safeName = escapeHtml(firstName || 'friend')
const safeQuestion = escapeHtml(question)
const safeAnswer = escapeHtml(answer.length > 600 ? answer.slice(0, 597) + '…' : answer).replace(/\n/g, '<br/>')
const safeUrl = escapeHtml(questionUrl)
const safeBody = escapeHtml(bodyText)
const safeSignoff = escapeHtml(signoff).replace(/\n/g, '<br/>')
const text = `Hi ${firstName || 'friend'},\n\n${bodyText}\n\nYour question: ${question}\n\nAnswer: ${answer}\n\nRead it at: ${questionUrl}\n\n${signoff}`
const html = `
<div style="margin:0;padding:0;background-color:#f5f1e8;font-family:Georgia,serif;color:#201a10;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#f5f1e8;">
<tr>
<td align="center" style="padding:28px 16px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:680px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
<tr>
<td style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">
<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>
<h1 style="margin:10px 0 0;color:#f4ead5;font-size:26px;line-height:1.2;">Your Question Was Answered</h1>
</td>
</tr>
<tr>
<td style="padding:26px 24px 18px;">
<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:16px;line-height:1.6;color:#201a10;">Hi ${safeName},</p>
<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeBody}</p>
<div style="background:#f7f2e5;border-left:3px solid #c8860a;padding:14px 18px;margin:0 0 20px;border-radius:0 8px 8px 0;">
<p style="margin:0 0 6px;font-family:Arial,sans-serif;font-size:13px;letter-spacing:0.06em;text-transform:uppercase;color:#a07830;">Your question</p>
<p style="margin:0;font-family:Georgia,serif;font-size:15px;line-height:1.6;color:#201a10;font-style:italic;">${safeQuestion}</p>
</div>
<div style="margin:0 0 24px;">
<p style="margin:0 0 6px;font-family:Arial,sans-serif;font-size:13px;letter-spacing:0.06em;text-transform:uppercase;color:#a07830;">Answer</p>
<p style="margin:0;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeAnswer}</p>
</div>
<a href="${safeUrl}" style="display:inline-block;background:#c8860a;color:#ffffff;font-family:Arial,sans-serif;font-size:14px;font-weight:bold;text-decoration:none;padding:12px 24px;border-radius:6px;">${escapeHtml(ctaLabel)}</a>
</td>
</tr>
<tr>
<td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:14px 24px;">
<p style="margin:0 0 12px;font-family:Arial,sans-serif;font-size:13px;line-height:1.6;color:#201a10;">${safeSignoff}</p>
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.5;color:#735a2b;">You received this because you requested a notification when this question was answered.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
`
return { subject, text, html }
}
// ── Transactional email senders ────────────────────────────────────────────
export async function sendStudyWelcomeEmail(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.studyWelcomeEmailSubject?.trim() || 'Welcome to the Study Community'
const bodyText = cfg.studyWelcomeEmailBody?.trim() || 'Your student account is ready. Open your studies and continue learning, or manage your account details anytime.'
const ctaLabel = cfg.studyWelcomeEmailCtaLabel?.trim() || 'Open Studies'
const studiesUrl = buildAbsoluteUrl(baseUrl, cfg.studyWelcomeEmailCtaPath?.trim() || '/study')
const signoff = cfg.studyWelcomeEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate'
const bodyHtml = (
`<p style="margin:0 0 16px;">Welcome, <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>.</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</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: `Welcome, ${namePart}.\n\n${bodyText}\n\nOpen studies: ${studiesUrl}\nManage account: ${accountUrl}\n\n${signoff}`,
html: buildBrandedEmailHtml({
title: 'Welcome to the Study Community',
eyebrow: 'Study Account',
bodyHtml,
ctaLabel,
ctaUrl: studiesUrl,
footerHtml: footerHtml + `<p style="margin:14px 0 0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;"><a href="${escapeHtml(accountUrl)}" target="_blank" style="color:#c9a84c;text-decoration:none;">Manage your account</a></p>`,
}),
})
if (error) console.error('[study-signup] welcome email send error:', error)
} catch (err) {
console.error('[study-signup] welcome email exception:', err)
}
}
export async function sendEmailOtp(email, code) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const cfg = state.cachedSiteContent ?? {}
const subject = cfg.twoFaOtpEmailSubject?.trim() || 'Your sign-in code — Verse by Verse with Nate'
const bodyText = cfg.twoFaOtpEmailBody?.trim() || 'Your two-factor sign-in code is below. Enter it to complete sign-in.'
const expiryText = cfg.twoFaOtpEmailExpiry?.trim() || 'This code expires in 10 minutes. If you did not request this, you can ignore this message.'
await resend.emails.send({
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
to: [email],
subject,
text: `${bodyText}\n\n${code}\n\n${expiryText}\n\nVerse by Verse with Nate`,
html: buildBrandedEmailHtml({
title: 'Your Sign-In Code',
eyebrow: 'Account Security',
bodyHtml:
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>` +
`<p style="margin:0 0 16px;font-size:2.5rem;font-weight:700;letter-spacing:0.35em;color:#c9a84c;font-family:monospace;">${code}</p>` +
`<p style="margin:0 0 16px;font-size:0.9rem;color:#7a7060;">${escapeHtml(expiryText)}</p>`,
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">Verse by Verse with Nate</p>`,
}),
})
} catch (err) {
console.error('[email-otp] send error:', err)
}
}
export async function sendStudyAccountDeletedEmail(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 subject = cfg.studyDeletedEmailSubject?.trim() || 'Your study account was deleted'
const bodyText = cfg.studyDeletedEmailBody?.trim() || 'This confirms your study account and saved notes were deleted. If this was not you, please contact us immediately.'
const ctaLabel = cfg.studyDeletedEmailCtaLabel?.trim() || 'Create a New Account'
const signupUrl = buildAbsoluteUrl(baseUrl, cfg.studyDeletedEmailCtaPath?.trim() || '/study/signup')
const signoff = cfg.studyDeletedEmailSignoff?.trim() || 'Verse by Verse with Nate'
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 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: `Hi ${namePart},\n\n${bodyText}\n\nCreate a new account anytime: ${signupUrl}\n\n${signoff}`,
html: buildBrandedEmailHtml({
title: 'Study Account Deleted',
eyebrow: 'Account Update',
bodyHtml,
ctaLabel,
ctaUrl: signupUrl,
footerHtml,
}),
})
if (error) console.error('[study-account] delete email send error:', error)
} catch (err) {
console.error('[study-account] delete email exception:', err)
}
}
export async function sendStudyReminderEmail(email, displayName, studyTitle, sectionTitle, sectionReference, sectionUrl) {
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 subjectPrefix = cfg.studyReminderEmailSubjectPrefix?.trim() || 'New lesson available:'
const bodyText = cfg.studyReminderEmailBody?.trim() || 'A new lesson has been unlocked in your study track. Open it below to continue where you left off.'
const ctaLabel = cfg.studyReminderEmailCtaLabel?.trim() || 'Open the Lesson'
const signoff = cfg.studyReminderEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate'
const subject = process.env.RESEND_REMINDER_SUBJECT ?? `${subjectPrefix} ${sectionTitle}`
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>` +
`<p style="margin:0 0 16px;"><strong style="color:#f0ead8;">${escapeHtml(sectionTitle)}</strong> (${escapeHtml(sectionReference)}) — ${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: `Hi ${namePart},\n\n${bodyText}\n\n${sectionTitle} (${sectionReference}) — ${studyTitle}\n\nOpen it here: ${sectionUrl}\n\n${signoff}`,
html: buildBrandedEmailHtml({
title: 'New Lesson Available',
eyebrow: 'Study Reminder',
bodyHtml,
ctaLabel,
ctaUrl: sectionUrl,
footerHtml,
}),
})
if (error) console.error('[study-reminder] send error:', error)
} catch (err) {
console.error('[study-reminder] send exception:', err)
}
}
const REENGAGEMENT_DEFAULTS = {
t1: { subject: 'Your study is waiting for you', body: "It's been a while — your progress is saved and your next lesson is ready whenever you are.", cta: 'Continue Studying' },
t2: { subject: "Don't lose your momentum", body: 'Every note and completed lesson is still right there. A little each day adds up — come back and continue.', cta: 'Return to Your Study' },
t3: { subject: 'Your progress is still here', body: "Your study progress is still intact and waiting. There's no deadline — come back whenever you're ready.", cta: 'Open Your Study' },
}
function getReengagementCopy(tier) {
const cfg = state.cachedSiteContent ?? {}
const t = tier === 't1' ? '1' : tier === 't2' ? '2' : tier === 't3' ? '3' : null
const def = REENGAGEMENT_DEFAULTS[tier] ?? REENGAGEMENT_DEFAULTS.t1
if (!t) return def
return {
subject: cfg[`studyReengagementT${t}Subject`]?.trim() || def.subject,
body: cfg[`studyReengagementT${t}Body`]?.trim() || def.body,
cta: cfg[`studyReengagementT${t}Cta`]?.trim() || def.cta,
}
}
export async function sendStudyReengagementEmail(email, displayName, studyTitle, studyUrl, tier, unsubUrl) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const copy = getReengagementCopy(tier)
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
const subject = `${copy.subject}${studyTitle ? `${studyTitle}` : ''}`
const bodyHtml = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(copy.body)}${studyTitle ? ` Your current study: <strong style="color:#f0ead8;">${escapeHtml(studyTitle)}</strong>.` : ''}</p>`
)
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;">Grace and peace,<br/>Verse by Verse with Nate</p>${unsubLine}`
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
to: [email],
subject,
text: `Hi ${namePart},\n\n${copy.body}${studyTitle ? ` Your current study: ${studyTitle}.` : ''}\n\n${studyUrl}\n\nGrace and peace,\nVerse by Verse with Nate${unsubUrl ? `\n\nUnsubscribe: ${unsubUrl}` : ''}`,
html: buildBrandedEmailHtml({
title: copy.subject,
eyebrow: 'Study Reminder',
bodyHtml,
ctaLabel: copy.cta,
ctaUrl: studyUrl,
footerHtml,
}),
})
if (error) console.error('[study-reengagement] send error:', error)
} catch (err) {
console.error('[study-reengagement] send exception:', err)
}
}
+536
View File
@@ -0,0 +1,536 @@
import { randomUUID } from 'node:crypto'
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_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', '/about', '/contact', '/episodes', '/resources', '/study', '/study/titus', '/study/colossians'],
}
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 function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
export function splitName(fullName) {
const parts = String(fullName).trim().split(/\s+/).filter(Boolean)
return {
firstName: parts[0] ?? '',
lastName: parts.slice(1).join(' '),
}
}
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 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 normalizeSitemapPath(value) {
if (typeof value !== 'string') return ''
const trimmed = value.trim()
if (!trimmed) return ''
if (trimmed === '/') return '/'
return normalizeRedirectPath(trimmed)
}
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
}
export function sanitizeFeaturedLinks(value) {
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => {
const discussionQuestions = Array.isArray(item.discussionQuestions)
? item.discussionQuestions
.filter(question => typeof question === 'string')
.map(question => question.trim())
.filter(Boolean)
.slice(0, 30)
: []
return {
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '',
episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '',
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
url: sanitizeUrl(item.url),
embedUrl: sanitizeUrl(item.embedUrl),
showNotes: typeof item.showNotes === 'string' ? item.showNotes.trim().slice(0, 10000) : '',
discussionQuestions,
}
})
.filter(
item =>
item.title ||
item.summary ||
item.url ||
item.embedUrl ||
item.showNotes ||
item.discussionQuestions.length > 0,
)
}
function sanitizeCustomLinks(value) {
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => {
const placement =
item?.placement === 'platforms'
|| item?.placement === 'footer'
|| item?.placement === 'resources'
|| item?.placement === 'otherSites'
|| item?.placement === 'externalSites'
|| item?.placement === 'books'
|| item?.placement === 'recommended'
? item.placement
: 'footer'
return {
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
label: typeof item.label === 'string' ? item.label.trim().slice(0, 140) : '',
url: sanitizeUrl(item.url),
imageUrl: sanitizeUrl(item.imageUrl),
description: typeof item.description === 'string' ? item.description.trim().slice(0, 4000) : '',
amazonUrl: sanitizeUrl(item.amazonUrl),
amazonLabel: typeof item.amazonLabel === 'string' ? item.amazonLabel.trim().slice(0, 120) : '',
tags: Array.isArray(item.tags)
? item.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean).slice(0, 20)
: [],
placement,
}
})
.filter(item => item.label && (item.url || item.amazonUrl || item.placement === 'books' || item.placement === 'recommended' || item.placement === 'resources'))
}
function sanitizeCustomBlocks(value) {
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
heading: typeof item.heading === 'string' ? item.heading.trim().slice(0, 140) : '',
body: typeof item.body === 'string' ? item.body.trim().slice(0, 4000) : '',
page: item?.page === 'homepage' || item?.page === 'start-here' || item?.page === 'episodes' || item?.page === 'downloads' || item?.page === 'about' || item?.page === 'contact' || item?.page === 'questions'
? item.page
: 'downloads',
linkUrl: sanitizeUrl(item.linkUrl),
linkLabel: typeof item.linkLabel === 'string' ? item.linkLabel.trim().slice(0, 120) : '',
}))
.filter(item => item.heading || item.body)
}
function sanitizeArchivedSeriesResourceLinks(value) {
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
label: typeof item.label === 'string' ? item.label.trim().slice(0, 120) : '',
description: typeof item.description === 'string' ? item.description.trim().slice(0, 4000) : '',
url: sanitizeUrl(item.url),
amazonUrl: sanitizeUrl(item.amazonUrl),
amazonLabel: typeof item.amazonLabel === 'string' ? item.amazonLabel.trim().slice(0, 120) : '',
}))
.filter(item => item.label && item.url)
}
function sanitizeArchivedSeriesNotes(value) {
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
heading: typeof item.heading === 'string' ? item.heading.trim().slice(0, 140) : '',
body: typeof item.body === 'string' ? item.body.trim().slice(0, 4000) : '',
}))
.filter(item => item.heading || item.body)
}
function sanitizeColossiansStudySections(value) {
function normalizeReleasedAt(value) {
if (typeof value !== 'string' || !value.trim()) return ''
const parsed = Date.parse(value.trim())
if (!Number.isFinite(parsed)) return ''
return new Date(parsed).toISOString().replace('.000Z', 'Z')
}
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => {
const releasedAt = normalizeReleasedAt(item.releasedAt)
return {
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
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) : '',
title: typeof item.title === 'string' ? item.title.trim().slice(0, 160) : '',
audioEmbedUrl: sanitizeUrl(item.audioEmbedUrl),
passageText: typeof item.passageText === 'string' ? item.passageText.trim().slice(0, 12000) : '',
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
commentary: typeof item.commentary === 'string' ? item.commentary.trim().slice(0, 12000) : '',
greekNotes: Array.isArray(item.greekNotes)
? item.greekNotes.filter(note => typeof note === 'string').map(note => note.trim()).filter(Boolean).slice(0, 20)
: [],
studyQuestions: Array.isArray(item.studyQuestions)
? item.studyQuestions.filter(question => typeof question === 'string').map(question => question.trim()).filter(Boolean).slice(0, 20)
: [],
announcement: typeof item.announcement === 'string' ? item.announcement.trim().slice(0, 600) : '',
checkpointPrompt: typeof item.checkpointPrompt === 'string' ? item.checkpointPrompt.trim().slice(0, 600) : '',
checkpointQuestions: Array.isArray(item.checkpointQuestions)
? item.checkpointQuestions.filter(question => typeof question === 'string').map(question => question.trim()).filter(Boolean).slice(0, 20)
: [],
...(releasedAt ? { releasedAt } : {}),
}
})
.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 sanitizeStudies(value) {
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
slug: typeof item.slug === 'string' ? item.slug.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') : '',
title: typeof item.title === 'string' ? item.title.trim().slice(0, 160) : '',
description: typeof item.description === 'string' ? item.description.trim().slice(0, 800) : '',
homepageEyebrow: typeof item.homepageEyebrow === 'string' ? item.homepageEyebrow.trim().slice(0, 80) : '',
showOnHomepage: item.showOnHomepage === true,
showNewTag: item.showNewTag === true,
newTagLabel: typeof item.newTagLabel === 'string' ? item.newTagLabel.trim().slice(0, 24) : '',
status: item.status === 'planned' ? 'planned' : 'active',
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,
completionBadge: typeof item.completionBadge === 'string' ? item.completionBadge.trim().slice(0, 120) : '',
numberOfChapters: Number.isInteger(item.numberOfChapters) && item.numberOfChapters >= 1 && item.numberOfChapters <= 999 ? item.numberOfChapters : 1,
sections: sanitizeColossiansStudySections(item.sections),
}))
.filter(item => item.slug && item.title)
}
function sanitizeArchivedSeries(value) {
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
label: typeof item.label === 'string' ? item.label.trim().slice(0, 140) : '',
title: typeof item.title === 'string' ? item.title.trim().slice(0, 200) : '',
description: typeof item.description === 'string' ? item.description.trim().slice(0, 1000) : '',
imageUrl: sanitizeUrl(item.imageUrl),
listenUrl: sanitizeUrl(item.listenUrl),
studyGuideTitle: typeof item.studyGuideTitle === 'string' ? item.studyGuideTitle.trim().slice(0, 140) : '',
studyGuideDescription: typeof item.studyGuideDescription === 'string' ? item.studyGuideDescription.trim().slice(0, 4000) : '',
studyGuideUrl: sanitizeUrl(item.studyGuideUrl),
resourceLinks: sanitizeArchivedSeriesResourceLinks(item.resourceLinks),
notes: sanitizeArchivedSeriesNotes(item.notes),
episodeRange:
Number.isInteger(item?.episodeRange?.from)
&& Number.isInteger(item?.episodeRange?.to)
&& item.episodeRange.from > 0
&& item.episodeRange.to >= item.episodeRange.from
? { from: item.episodeRange.from, to: item.episodeRange.to }
: undefined,
}))
.filter(
item =>
item.title ||
item.description ||
item.resourceLinks.length > 0 ||
item.notes.length > 0,
)
}
export function sanitizeSiteContent(siteContent) {
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return {}
const seo = siteContent.seo && typeof siteContent.seo === 'object' ? siteContent.seo : {}
const legal = siteContent.legal && typeof siteContent.legal === 'object' ? siteContent.legal : {}
return {
...siteContent,
customLinks: sanitizeCustomLinks(siteContent.customLinks),
customBlocks: sanitizeCustomBlocks(siteContent.customBlocks),
archivedSeries: sanitizeArchivedSeries(siteContent.archivedSeries),
studies: sanitizeStudies(siteContent.studies),
colossiansStudySections: sanitizeColossiansStudySections(siteContent.colossiansStudySections),
redirects: sanitizeRedirectRules(siteContent.redirects),
podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS),
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) : '',
welcomeEmailAppleBtnLabel: typeof siteContent.welcomeEmailAppleBtnLabel === 'string' ? siteContent.welcomeEmailAppleBtnLabel.trim().slice(0, 80) : '',
welcomeEmailStartHereLinkLabel: typeof siteContent.welcomeEmailStartHereLinkLabel === 'string' ? siteContent.welcomeEmailStartHereLinkLabel.trim().slice(0, 80) : '',
studyWelcomeEmailSubject: typeof siteContent.studyWelcomeEmailSubject === 'string' ? siteContent.studyWelcomeEmailSubject.trim().slice(0, 200) : '',
studyWelcomeEmailBody: typeof siteContent.studyWelcomeEmailBody === 'string' ? siteContent.studyWelcomeEmailBody.trim().slice(0, 1000) : '',
studyWelcomeEmailCtaLabel: typeof siteContent.studyWelcomeEmailCtaLabel === 'string' ? siteContent.studyWelcomeEmailCtaLabel.trim().slice(0, 80) : '',
studyWelcomeEmailCtaPath: typeof siteContent.studyWelcomeEmailCtaPath === 'string' ? siteContent.studyWelcomeEmailCtaPath.trim().slice(0, 200) : '',
studyWelcomeEmailSignoff: typeof siteContent.studyWelcomeEmailSignoff === 'string' ? siteContent.studyWelcomeEmailSignoff.trim().slice(0, 200) : '',
studyDeletedEmailSubject: typeof siteContent.studyDeletedEmailSubject === 'string' ? siteContent.studyDeletedEmailSubject.trim().slice(0, 200) : '',
studyDeletedEmailBody: typeof siteContent.studyDeletedEmailBody === 'string' ? siteContent.studyDeletedEmailBody.trim().slice(0, 1000) : '',
studyDeletedEmailCtaLabel: typeof siteContent.studyDeletedEmailCtaLabel === 'string' ? siteContent.studyDeletedEmailCtaLabel.trim().slice(0, 80) : '',
studyDeletedEmailCtaPath: typeof siteContent.studyDeletedEmailCtaPath === 'string' ? siteContent.studyDeletedEmailCtaPath.trim().slice(0, 200) : '',
studyDeletedEmailSignoff: typeof siteContent.studyDeletedEmailSignoff === 'string' ? siteContent.studyDeletedEmailSignoff.trim().slice(0, 200) : '',
studyReminderEmailSubjectPrefix: typeof siteContent.studyReminderEmailSubjectPrefix === 'string' ? siteContent.studyReminderEmailSubjectPrefix.trim().slice(0, 200) : '',
studyReminderEmailBody: typeof siteContent.studyReminderEmailBody === 'string' ? siteContent.studyReminderEmailBody.trim().slice(0, 1000) : '',
studyReminderEmailCtaLabel: typeof siteContent.studyReminderEmailCtaLabel === 'string' ? siteContent.studyReminderEmailCtaLabel.trim().slice(0, 80) : '',
studyReminderEmailSignoff: typeof siteContent.studyReminderEmailSignoff === 'string' ? siteContent.studyReminderEmailSignoff.trim().slice(0, 200) : '',
emailChangeSubject: typeof siteContent.emailChangeSubject === 'string' ? siteContent.emailChangeSubject.trim().slice(0, 200) : '',
emailChangeBody: typeof siteContent.emailChangeBody === 'string' ? siteContent.emailChangeBody.trim().slice(0, 500) : '',
emailChangeCtaLabel: typeof siteContent.emailChangeCtaLabel === 'string' ? siteContent.emailChangeCtaLabel.trim().slice(0, 80) : '',
twoFaOtpEmailSubject: typeof siteContent.twoFaOtpEmailSubject === 'string' ? siteContent.twoFaOtpEmailSubject.trim().slice(0, 200) : '',
twoFaOtpEmailBody: typeof siteContent.twoFaOtpEmailBody === 'string' ? siteContent.twoFaOtpEmailBody.trim().slice(0, 500) : '',
twoFaOtpEmailExpiry: typeof siteContent.twoFaOtpEmailExpiry === 'string' ? siteContent.twoFaOtpEmailExpiry.trim().slice(0, 300) : '',
seo: {
title: typeof seo.title === 'string' && seo.title.trim() ? seo.title.trim().slice(0, 120) : DEFAULT_SEO.title,
description: typeof seo.description === 'string' && seo.description.trim() ? seo.description.trim().slice(0, 240) : DEFAULT_SEO.description,
ogTitle: typeof seo.ogTitle === 'string' && seo.ogTitle.trim() ? seo.ogTitle.trim().slice(0, 120) : DEFAULT_SEO.ogTitle,
ogDescription: typeof seo.ogDescription === 'string' && seo.ogDescription.trim() ? seo.ogDescription.trim().slice(0, 240) : DEFAULT_SEO.ogDescription,
ogImage: sanitizeUrl(seo.ogImage) || DEFAULT_SEO.ogImage,
canonicalUrl: sanitizeUrl(seo.canonicalUrl) || DEFAULT_SEO.canonicalUrl,
robotsPolicy: typeof seo.robotsPolicy === 'string' && seo.robotsPolicy.trim() ? seo.robotsPolicy.trim() : DEFAULT_SEO.robotsPolicy,
sitemapPaths: Array.isArray(seo.sitemapPaths)
? seo.sitemapPaths.map(pathItem => normalizeSitemapPath(pathItem)).filter(Boolean)
: [...DEFAULT_SEO.sitemapPaths],
},
legal: {
privacyTitle: typeof legal.privacyTitle === 'string' && legal.privacyTitle.trim() ? legal.privacyTitle.trim().slice(0, 120) : DEFAULT_LEGAL.privacyTitle,
privacyBody: Array.isArray(legal.privacyBody) && legal.privacyBody.length > 0
? legal.privacyBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20)
: [...DEFAULT_LEGAL.privacyBody],
termsTitle: typeof legal.termsTitle === 'string' && legal.termsTitle.trim() ? legal.termsTitle.trim().slice(0, 120) : DEFAULT_LEGAL.termsTitle,
termsBody: Array.isArray(legal.termsBody) && legal.termsBody.length > 0
? legal.termsBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20)
: [...DEFAULT_LEGAL.termsBody],
},
}
}
export function escapeXml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
export function buildAbsoluteUrl(baseUrl, maybeRelativePath) {
const safeBase = typeof baseUrl === 'string' && baseUrl.trim() ? baseUrl.trim() : DEFAULT_SEO.canonicalUrl
const root = safeBase.endsWith('/') ? safeBase.slice(0, -1) : safeBase
if (typeof maybeRelativePath !== 'string' || !maybeRelativePath.trim()) return root
const value = maybeRelativePath.trim()
if (/^https?:\/\//i.test(value)) return value
if (value.startsWith('/')) return `${root}${value}`
return `${root}/${value}`
}
export function injectSeoIntoHtml(html, siteContent) {
const seo = siteContent?.seo ?? DEFAULT_SEO
const title = seo.title || DEFAULT_SEO.title
const description = seo.description || DEFAULT_SEO.description
const ogTitle = seo.ogTitle || title
const ogDescription = seo.ogDescription || description
const canonical = buildAbsoluteUrl(seo.canonicalUrl || DEFAULT_SEO.canonicalUrl, '/')
const ogImage = buildAbsoluteUrl(canonical, seo.ogImage || DEFAULT_SEO.ogImage)
const robots = seo.robotsPolicy || DEFAULT_SEO.robotsPolicy
return html
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${escapeHtml(title)}</title>`)
.replace(/<meta name="description" content="[^"]*"\s*\/?>/i, `<meta name="description" content="${escapeHtml(description)}" />`)
.replace(/<meta name="robots" content="[^"]*"\s*\/?>/i, `<meta name="robots" content="${escapeHtml(robots)}" />`)
.replace(/<meta property="og:title" content="[^"]*"\s*\/?>/i, `<meta property="og:title" content="${escapeHtml(ogTitle)}" />`)
.replace(/<meta property="og:description" content="[^"]*"\s*\/?>/i, `<meta property="og:description" content="${escapeHtml(ogDescription)}" />`)
.replace(/<meta property="og:image" content="[^"]*"\s*\/?>/i, `<meta property="og:image" content="${escapeHtml(ogImage)}" />`)
.replace(/<meta property="og:image:secure_url" content="[^"]*"\s*\/?>/i, `<meta property="og:image:secure_url" content="${escapeHtml(ogImage)}" />`)
.replace(/<meta property="og:url" content="[^"]*"\s*\/?>/i, `<meta property="og:url" content="${escapeHtml(canonical)}" />`)
.replace(/<link rel="canonical" href="[^"]*"\s*\/?>/i, `<link rel="canonical" href="${escapeHtml(canonical)}" />`)
}
export function normalizeAssetBaseName(name) {
if (typeof name !== 'string') return `upload-${Date.now()}`
const cleaned = name
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
return cleaned || `upload-${Date.now()}`
}
export function inferImageExtensionFromDataUrl(dataUrl) {
if (typeof dataUrl !== 'string') return null
if (dataUrl.startsWith('data:image/png;base64,')) return '.png'
if (dataUrl.startsWith('data:image/jpeg;base64,')) return '.jpg'
if (dataUrl.startsWith('data:image/webp;base64,')) return '.webp'
if (dataUrl.startsWith('data:image/gif;base64,')) return '.gif'
if (dataUrl.startsWith('data:application/pdf;base64,')) return '.pdf'
if (dataUrl.startsWith('data:application/msword;base64,')) return '.doc'
if (dataUrl.startsWith('data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,')) return '.docx'
return 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 getClientIp(req) {
// Use req.ip: Express derives this from x-forwarded-for according to the
// configured trust proxy hop count, preventing header spoofing by clients.
return normalizeIp(req.ip)
}
export function parseCookies(cookieHeader) {
if (!cookieHeader) return {}
return cookieHeader
.split(';')
.map(v => v.trim())
.filter(Boolean)
.reduce((acc, part) => {
const idx = part.indexOf('=')
if (idx === -1) return acc
const key = part.slice(0, idx).trim()
const value = part.slice(idx + 1).trim()
try {
acc[key] = decodeURIComponent(value)
} catch {
acc[key] = value
}
return acc
}, {})
}
export function hasVisitorConsent(req) {
const cookies = parseCookies(req.headers.cookie)
return cookies['vbn_analytics_consent'] === 'yes'
}
// Secure cookies are required in production unless explicitly disabled with
// ALLOW_INSECURE_COOKIES=true — needed when the app is reached over plain
// HTTP (e.g. by LAN/VPN IP during a server migration, before TLS is set up),
// because browsers silently drop Secure cookies on http:// origins.
export function cookieSecureFlag() {
if (process.env.ALLOW_INSECURE_COOKIES === 'true') return ''
return process.env.NODE_ENV === 'production' ? '; Secure' : ''
}
export function setConsentCookie(res, consent) {
const value = consent ? 'yes' : 'no'
res.append('Set-Cookie', `vbn_analytics_consent=${value}; Max-Age=31536000; Path=/; SameSite=Lax${cookieSecureFlag()}`)
}
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'
)
}
+15
View File
@@ -0,0 +1,15 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// Base directory resolution lives in its own module (no app imports) so that
// both config.js and auth.js can use DATA_DIR without a circular dependency.
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// server/paths.js is inside server/, so the project root is one level up
export const ROOT_DIR = path.resolve(__dirname, '..')
const DEFAULT_DATA_DIR = path.join(ROOT_DIR, 'data')
const configuredDataDir = typeof process.env.SITEFORGE_DATA_DIR === 'string' ? process.env.SITEFORGE_DATA_DIR.trim() : ''
export const DATA_DIR = configuredDataDir
? (path.isAbsolute(configuredDataDir) ? configuredDataDir : path.resolve(ROOT_DIR, configuredDataDir))
: DEFAULT_DATA_DIR
+111
View File
@@ -0,0 +1,111 @@
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
}
}
+196
View File
@@ -0,0 +1,196 @@
import { mkdir, stat, unlink, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { inferImageExtensionFromDataUrl, normalizeAssetBaseName } from '../helpers.js'
import { requireAdminAuth } from '../auth.js'
import { UPLOADS_DIR } from '../config.js'
import { state } from '../state.js'
import {
listUploadedAssets,
readUploadsMetadata,
writeUploadsMetadata,
getUserNotesFilePath,
queueStudyUsersWrite,
} from '../data.js'
import {
hashStudyPassword,
getStudyCatalog,
normalizeStudySlug,
isEnrollableStudySlug,
} from '../study-helpers.js'
export function register(app) {
app.get('/api/admin-assets', requireAdminAuth, async (_req, res) => {
try {
const assets = await listUploadedAssets()
res.json({ assets })
} catch {
res.status(500).json({ message: 'Could not list uploaded assets.' })
}
})
app.post('/api/admin-assets', requireAdminAuth, async (req, res) => {
try {
const filename = typeof req.body?.filename === 'string' ? req.body.filename : ''
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
const ext = inferImageExtensionFromDataUrl(dataUrl)
if (!ext) {
res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, GIF, PDF, DOC, or DOCX data URL.' })
return
}
const base64 = dataUrl.split(',')[1] ?? ''
const buffer = Buffer.from(base64, 'base64')
if (buffer.length === 0 || buffer.length > (8 * 1024 * 1024)) {
res.status(400).json({ message: 'Upload must be between 1 byte and 8MB.' })
return
}
const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, ''))
const finalName = `${baseName}-${Date.now()}${ext}`
await mkdir(UPLOADS_DIR, { recursive: true })
await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
const metadata = await readUploadsMetadata()
metadata[finalName] = []
await writeUploadsMetadata(metadata)
res.json({ ok: true, asset: { filename: finalName, url: `/uploads/${finalName}` } })
} catch {
res.status(500).json({ message: 'Upload failed.' })
}
})
app.patch('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => {
try {
const { filename } = req.params
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) {
res.status(400).json({ message: 'Invalid filename.' })
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()
metadata[filename] = tags
await writeUploadsMetadata(metadata)
res.json({ ok: true, tags })
} catch {
res.status(404).json({ message: 'Asset not found.' })
}
})
app.delete('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => {
try {
const { filename } = req.params
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) {
res.status(400).json({ message: 'Invalid filename.' })
return
}
await unlink(path.join(UPLOADS_DIR, filename))
const metadata = await readUploadsMetadata()
delete metadata[filename]
await writeUploadsMetadata(metadata)
res.json({ ok: true })
} catch {
res.status(404).json({ message: 'Asset not found.' })
}
})
// ── Admin: Study User Management ─────────────────────────────────────────
app.get('/api/admin/study-users', requireAdminAuth, async (req, res) => {
const catalog = getStudyCatalog()
const users = await Promise.all(state.studyUsers.map(async user => {
let noteCount = 0
try {
const { readFile } = await import('node:fs/promises')
const notesRaw = await readFile(getUserNotesFilePath(user.id), 'utf8').catch(() => '{}')
const notes = JSON.parse(notesRaw)
noteCount = Object.values(notes).filter(n => typeof n === 'string' && n.trim()).length
} catch { /* ignore */ }
const enrolledStudies = (user.enrolledStudySlugs ?? []).map(slug => {
const study = catalog.find(s => s.slug === slug)
return study ? { slug, title: study.title } : { slug, title: slug }
})
return {
id: user.id,
username: user.username,
displayName: user.displayName ?? '',
createdAt: user.createdAt ?? null,
lastLoginAt: user.lastLoginAt ?? null,
enrolledStudies,
noteCount,
subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true,
}
}))
res.json({ users })
})
app.patch('/api/admin/study-users/:id', requireAdminAuth, (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 { displayName, newPassword, addEnrollment, removeEnrollment } = req.body ?? {}
if (typeof displayName === 'string') {
user.displayName = displayName.trim().slice(0, 80)
}
if (typeof newPassword === 'string') {
if (newPassword.length < 8 || newPassword.length > 200) {
res.status(400).json({ message: 'Password must be 8200 characters.' }); return
}
user.passwordHash = hashStudyPassword(newPassword)
for (const [token, session] of state.studySessions) {
if (session.userId === user.id) state.studySessions.delete(token)
}
}
if (typeof addEnrollment === 'string' && addEnrollment.trim()) {
const slug = normalizeStudySlug(addEnrollment)
if (!slug || !isEnrollableStudySlug(slug)) {
res.status(400).json({ message: `Unknown study slug: ${addEnrollment.trim()}` }); return
}
if (!Array.isArray(user.enrolledStudySlugs)) user.enrolledStudySlugs = []
if (!user.enrolledStudySlugs.includes(slug)) user.enrolledStudySlugs.push(slug)
}
if (typeof removeEnrollment === 'string' && removeEnrollment.trim()) {
const slug = removeEnrollment.trim()
user.enrolledStudySlugs = (user.enrolledStudySlugs ?? []).filter(s => s !== slug)
}
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
res.json({ ok: true, displayName: user.displayName, enrolledStudySlugs: user.enrolledStudySlugs })
})
app.delete('/api/admin/study-users/:id', 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 }
for (const [token, session] of state.studySessions) {
if (session.userId === user.id) state.studySessions.delete(token)
}
state.studyUsers = state.studyUsers.filter(u => u.id !== user.id)
queueStudyUsersWrite()
state.studyNotesCache.delete(user.id)
try { await unlink(getUserNotesFilePath(user.id)) } catch { /* no notes file is fine */ }
res.json({ ok: true })
})
}
+165
View File
@@ -0,0 +1,165 @@
import rateLimit from 'express-rate-limit'
import qrcode from 'qrcode'
import { parseCookies } from '../helpers.js'
import { APP_VERSION, GIT_COMMIT } from '../config.js'
import {
isAdminPasswordConfigured,
isValidAdminSession,
requireAdminAuth,
setAdminSessionCookie,
clearAdminSessionCookie,
createAdminSession,
deleteAdminSession,
isAdminPasswordValid,
isTotpEnabled,
loadTotpState,
saveTotpState,
generateTotpSecret,
getTotpUri,
verifyTotpCode,
generateRecoveryCodes,
hashRecoveryCode,
consumeRecoveryCode,
createPendingSession,
consumePendingSession,
} from '../auth.js'
const ADMIN_SESSION_COOKIE = 'vbn_admin_session'
const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many login attempts. Please wait 15 minutes and try again.' },
skipSuccessfulRequests: true,
})
export function register(app) {
app.get('/api/admin-auth/status', async (req, res) => {
res.json({
authenticated: isValidAdminSession(req),
configured: isAdminPasswordConfigured(),
totpEnabled: await isTotpEnabled(),
version: APP_VERSION,
commit: GIT_COMMIT,
})
})
app.post('/api/admin-auth/login', loginRateLimiter, async (req, res) => {
const password = typeof req.body?.password === 'string' ? req.body.password : ''
if (!isAdminPasswordConfigured()) {
res.status(503).json({ message: 'ADMIN_PASSWORD is not configured on the server.' })
return
}
if (!isAdminPasswordValid(password)) {
res.status(401).json({ message: 'Invalid password.' })
return
}
const totpOn = await isTotpEnabled()
if (totpOn) {
const pendingToken = createPendingSession()
res.json({ totpRequired: true, pendingToken })
return
}
const sessionToken = createAdminSession()
setAdminSessionCookie(res, sessionToken)
res.json({ ok: true })
})
app.post('/api/admin-auth/totp-verify', loginRateLimiter, async (req, res) => {
const { pendingToken, code } = req.body ?? {}
if (!consumePendingSession(pendingToken)) {
res.status(401).json({ message: 'Session expired or invalid. Please sign in again.' })
return
}
const totpState = await loadTotpState()
if (!totpState?.secret || !totpState?.verified) {
res.status(400).json({ message: 'TOTP is not configured.' })
return
}
const codeStr = typeof code === 'string' ? code.trim() : ''
if (verifyTotpCode(totpState.secret, codeStr)) {
const sessionToken = createAdminSession()
setAdminSessionCookie(res, sessionToken)
res.json({ ok: true })
return
}
if (consumeRecoveryCode(totpState, codeStr)) {
await saveTotpState(totpState)
const sessionToken = createAdminSession()
setAdminSessionCookie(res, sessionToken)
res.json({ ok: true, usedRecoveryCode: true, remainingRecoveryCodes: totpState.hashedRecoveryCodes.length })
return
}
res.status(401).json({ message: 'Invalid code. Try again or use a recovery code.' })
})
app.post('/api/admin-auth/totp-setup-init', requireAdminAuth, async (req, res) => {
const secret = generateTotpSecret()
const uri = getTotpUri(secret)
const qrDataUrl = await qrcode.toDataURL(uri)
const existing = await loadTotpState()
await saveTotpState({ ...existing, secret, verified: false })
res.json({ qrDataUrl, secret })
})
app.post('/api/admin-auth/totp-setup-confirm', requireAdminAuth, async (req, res) => {
const { code } = req.body ?? {}
const totpState = await loadTotpState()
if (!totpState?.secret) {
res.status(400).json({ message: 'No TOTP setup in progress. Call /totp-setup-init first.' })
return
}
if (!verifyTotpCode(totpState.secret, typeof code === 'string' ? code.trim() : '')) {
res.status(401).json({ message: 'Code incorrect. Scan the QR code again and try once more.' })
return
}
const recoveryCodes = generateRecoveryCodes()
await saveTotpState({
secret: totpState.secret,
verified: true,
hashedRecoveryCodes: recoveryCodes.map(hashRecoveryCode),
enabledAt: new Date().toISOString(),
})
res.json({ ok: true, recoveryCodes })
})
app.post('/api/admin-auth/totp-disable', requireAdminAuth, async (req, res) => {
await saveTotpState({ secret: null, verified: false, hashedRecoveryCodes: [], disabledAt: new Date().toISOString() })
res.json({ ok: true })
})
app.post('/api/admin-auth/totp-regen-recovery', requireAdminAuth, async (req, res) => {
const totpState = await loadTotpState()
if (!totpState?.secret || !totpState?.verified) {
res.status(400).json({ message: 'TOTP is not enabled.' })
return
}
const recoveryCodes = generateRecoveryCodes()
await saveTotpState({ ...totpState, hashedRecoveryCodes: recoveryCodes.map(hashRecoveryCode) })
res.json({ ok: true, recoveryCodes })
})
app.post('/api/admin-auth/logout', (req, res) => {
const cookies = parseCookies(req.headers.cookie)
const sessionToken = cookies[ADMIN_SESSION_COOKIE]
deleteAdminSession(sessionToken)
clearAdminSessionCookie(res)
res.json({ ok: true })
})
}
+251
View File
@@ -0,0 +1,251 @@
import { mkdir, mkdtemp, readdir, rm, cp, writeFile, unlink } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import express from 'express'
import * as tar from 'tar'
import { requireAdminAuth } from '../auth.js'
import { DATA_DIR } from '../config.js'
import { state } from '../state.js'
import {
loadHitStatsFromDisk,
loadVisitorStatsFromDisk,
loadContactSubmissionsFromDisk,
loadReplyTemplatesFromDisk,
loadReplyHistoryFromDisk,
loadQuestionsFromDisk,
loadDraftQuestionsFromDisk,
loadStudyUsersFromDisk,
loadStudyCommunityFromDisk,
loadStudyRemindersFromDisk,
loadStudyCommentsFromDisk,
loadStudyCertificatesFromDisk,
loadEpisodeScriptsFromDisk,
migrateStudyNotesIfNeeded,
loadDownloadCountsFromDisk,
loadQrCodesFromDisk,
loadEpisodePlaysFromDisk,
loadPodcastChecklistFromDisk,
loadAnalyticsEventsFromDisk,
createBackupSnapshot,
refreshContentCaches,
} from '../data.js'
// Portable server settings captured into env-snapshot.env at export time so a
// backup carries the runtime configuration too (Docker deployments have no
// .env file — settings live in container env vars). Machine-specific vars
// (PORT, NODE_ENV, SITEFORGE_DATA_DIR, ALLOW_INSECURE_COOKIES, build info)
// are deliberately excluded. Values are NOT applied automatically on restore.
const ENV_SNAPSHOT_KEYS = [
'ADMIN_PASSWORD',
'RESEND_API_KEY',
'RESEND_CONTACTS_API_KEY',
'RESEND_WEBHOOK_TOKEN',
'INBOUND_EMAIL_SECRET',
'RESEND_FROM',
'RESEND_TO',
'RESEND_REPLY_TO',
'RESEND_AUTOMATION_WELCOME',
'RESEND_SEGMENT_ID',
'RESEND_REMINDER_SUBJECT',
'RESEND_WELCOME_SUBJECT',
'RESEND_WELCOME_IMAGE_URL',
'RESEND_WELCOME_EPISODE_URL',
'RESEND_WELCOME_WEBSITE_URL',
'RESEND_WELCOME_SPOTIFY_URL',
'RESEND_WELCOME_APPLE_URL',
'RESEND_WELCOME_AMAZON_URL',
'CACHE_PURGE_WEBHOOK_URL',
'DEPLOY_WEBHOOK_URL',
'CONTACT_EMAIL_COOLDOWN_MS',
'TRUST_PROXY_HOPS',
'TITUS_STUDY_FILE',
'TITUS_STUDY_DOWNLOAD_NAME',
]
// Write env snapshot into DATA_DIR for inclusion in the archive, then delete
// it immediately after the stream finishes so secrets don't linger on disk.
async function writeEnvSnapshot() {
const lines = [
'# Siteforge environment snapshot — regenerated on every full backup export.',
'# Contains secrets (admin password, API keys): keep this backup private.',
'# These values are NOT applied automatically on restore. Set them as',
'# container environment variables (or in .env) on the new server.',
`# Exported at ${new Date().toISOString()}`,
'',
]
for (const key of ENV_SNAPSHOT_KEYS) {
const value = process.env[key]
if (typeof value === 'string' && value.trim() !== '') lines.push(`${key}=${value}`)
}
await writeFile(path.join(DATA_DIR, 'env-snapshot.env'), `${lines.join('\n')}\n`, 'utf8')
}
async function deleteEnvSnapshot() {
await unlink(path.join(DATA_DIR, 'env-snapshot.env')).catch(() => {})
}
// Files that identify an archive as a Siteforge data backup. At least one
// must be present at the top level of an uploaded archive before we restore.
const KNOWN_DATA_FILES = [
'admin-content.json',
'admin-content-draft.json',
'study-users.json',
'hit-stats.json',
'visitor-stats.json',
'questions.json',
]
// Wait for every queued disk write so the archive reflects current state.
async function flushPendingWrites() {
await Promise.allSettled([
state.hitStatsWritePromise,
state.visitorStatsWritePromise,
state.contactSubmissionsWritePromise,
state.questionsWritePromise,
state.draftQuestionsWritePromise,
state.replyTemplatesWritePromise,
state.replyHistoryWritePromise,
state.podcastChecklistWritePromise,
state.studyUsersWritePromise,
state.studyCommunityWritePromise,
state.studyRemindersWritePromise,
state.studyCommentsWritePromise,
state.studyCertificatesWritePromise,
state.episodeScriptsWritePromise,
state.downloadCountsWritePromise,
state.episodePlaysWritePromise,
state.analyticsEventsWritePromise,
state.qrCodesWritePromise,
...state.studyNotesWriteQueues.values(),
...state.studyProgressWriteQueues.values(),
])
}
// Rebuild all in-memory state from whatever is now on disk (mirrors startup).
async function reloadStateFromDisk() {
state.studyNotesCache.clear()
state.studyNotesWriteQueues.clear()
state.studyProgressCache.clear()
state.studyProgressWriteQueues.clear()
// Restored study users may not match current sessions — force re-login.
state.studySessions.clear()
await Promise.all([
loadHitStatsFromDisk(),
loadVisitorStatsFromDisk(),
loadContactSubmissionsFromDisk(),
loadReplyTemplatesFromDisk(),
loadReplyHistoryFromDisk(),
loadQuestionsFromDisk(),
loadDraftQuestionsFromDisk(),
loadStudyUsersFromDisk(),
loadStudyCommunityFromDisk(),
loadStudyRemindersFromDisk(),
loadStudyCommentsFromDisk(),
loadStudyCertificatesFromDisk(),
loadEpisodeScriptsFromDisk(),
migrateStudyNotesIfNeeded(),
loadDownloadCountsFromDisk(),
loadQrCodesFromDisk(),
loadEpisodePlaysFromDisk(),
loadPodcastChecklistFromDisk(),
loadAnalyticsEventsFromDisk(),
refreshContentCaches(),
])
}
export function register(app) {
// Download the entire data directory as a tar.gz (excluding the automatic
// snapshot folder, which is derived from the other files).
app.get('/api/admin-backup/export', requireAdminAuth, async (_req, res) => {
try {
await flushPendingWrites()
await writeEnvSnapshot()
const entries = (await readdir(DATA_DIR)).filter(name => name !== 'backups')
if (entries.length === 0) {
res.status(500).json({ message: 'Data directory is empty — nothing to export.' })
return
}
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
res.setHeader('Content-Type', 'application/gzip')
res.setHeader('Content-Disposition', `attachment; filename="siteforge-data-${stamp}.tar.gz"`)
const archive = tar.create({ gzip: true, cwd: DATA_DIR, portable: true }, entries)
archive.on('error', err => {
console.error('[admin-backup] export stream failed:', err)
res.destroy(err)
})
archive.on('end', () => { deleteEnvSnapshot() })
res.on('close', () => { deleteEnvSnapshot() })
archive.pipe(res)
} catch (err) {
console.error('[admin-backup] export failed:', err)
deleteEnvSnapshot()
if (!res.headersSent) res.status(500).json({ message: 'Full backup export failed.' })
}
})
// Restore the entire data directory from an uploaded tar.gz produced by the
// export endpoint, then reload all in-memory state from the restored files.
app.post(
'/api/admin-backup/import',
requireAdminAuth,
express.raw({ type: () => true, limit: '500mb' }),
async (req, res) => {
let workDir = null
try {
const body = req.body
if (!Buffer.isBuffer(body) || body.length === 0) {
res.status(400).json({ message: 'Upload the .tar.gz file produced by the full backup export.' })
return
}
if (body[0] !== 0x1f || body[1] !== 0x8b) {
res.status(400).json({ message: 'File is not a gzip archive (.tar.gz expected).' })
return
}
workDir = await mkdtemp(path.join(os.tmpdir(), 'siteforge-import-'))
const archivePath = path.join(workDir, 'import.tar.gz')
await writeFile(archivePath, body)
const extractDir = path.join(workDir, 'extracted')
await mkdir(extractDir)
// node-tar strips absolute paths and rejects entries that escape cwd.
await tar.extract({ file: archivePath, cwd: extractDir })
const extractedEntries = await readdir(extractDir)
if (!extractedEntries.some(name => KNOWN_DATA_FILES.includes(name))) {
res.status(400).json({ message: 'Archive does not look like a Siteforge data backup.' })
return
}
// Settle queued writes so nothing overwrites the restored files, and
// keep a snapshot of the pre-import state in the backups folder.
await flushPendingWrites()
await createBackupSnapshot('pre-import')
// Replace current data with the archive contents. The snapshot folder
// is preserved unless the archive itself contains one.
const currentEntries = await readdir(DATA_DIR)
for (const name of currentEntries) {
if (name === 'backups' && !extractedEntries.includes('backups')) continue
await rm(path.join(DATA_DIR, name), { recursive: true, force: true })
}
for (const name of extractedEntries) {
await cp(path.join(extractDir, name), path.join(DATA_DIR, name), { recursive: true })
}
await reloadStateFromDisk()
await createBackupSnapshot('post-import')
res.json({ ok: true, restoredEntries: extractedEntries.length })
} catch (err) {
console.error('[admin-backup] import failed:', err)
res.status(500).json({ message: 'Full data restore failed. Check the server logs — data may need manual attention.' })
} finally {
if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {})
}
},
)
}
+334
View File
@@ -0,0 +1,334 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { sanitizeSiteContent } from '../helpers.js'
import { requireAdminAuth, isValidAdminSession } from '../auth.js'
import {
DATA_DIR,
DATA_FILE,
DRAFT_DATA_FILE,
QUESTIONS_FILE,
DEFAULT_SEO,
DEFAULT_LEGAL,
DEFAULT_REDIRECT_RULES,
DEFAULT_PODCAST_FEATURED_LINKS,
MAX_QUESTIONS,
EMPTY_HIT_STATS,
EMPTY_VISITOR_STATS,
} from '../config.js'
import { state } from '../state.js'
import {
loadSiteContentFile,
getStorageStatus,
refreshContentCaches,
queueHitStatsWrite,
queueVisitorStatsWrite,
queueContactSubmissionsWrite,
queueReplyTemplatesWrite,
queueReplyHistoryWrite,
queuePodcastChecklistWrite,
createBackupSnapshot,
listBackupPreviews,
readBackupPreview,
restoreFromBackup,
sanitizePodcastChecklist,
} from '../data.js'
import {
pruneStatsByDays,
filterSiteContentByReleaseDate,
} from '../study-helpers.js'
function invokeWebhook(url, action) {
if (!url) {
return Promise.resolve({ ok: false, message: `${action} webhook URL is not configured.` })
}
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, at: new Date().toISOString(), source: 'siteforge-admin' }),
})
.then(response => {
if (!response.ok) return { ok: false, message: `${action} webhook failed with ${response.status}.` }
return { ok: true, message: `${action} webhook triggered.` }
})
.catch(err => ({ ok: false, message: err instanceof Error ? err.message : `${action} webhook failed.` }))
}
export function register(app) {
app.get('/api/admin-content', async (req, res) => {
const source = req.query?.source === 'draft' ? 'draft' : 'published'
if (source === 'draft' && !isValidAdminSession(req)) {
res.status(401).json({ message: 'Unauthorized' })
return
}
try {
const parsed = await loadSiteContentFile(source === 'draft' ? DRAFT_DATA_FILE : DATA_FILE)
if (source === 'published') {
const safeSiteContent = filterSiteContentByReleaseDate(parsed.siteContent)
res.json({ ...parsed, siteContent: safeSiteContent })
return
}
res.json(parsed)
} catch {
if (source === 'draft') {
res.status(404).json({ message: 'No saved draft content file yet.' })
return
}
res.status(404).json({ message: 'No saved admin content file yet.' })
}
})
app.get('/api/admin-content-state', requireAdminAuth, (_req, res) => {
res.json({
publishState: state.publishState,
hasDraft: Boolean(state.cachedDraftSiteContent),
hasPublished: Boolean(state.cachedSiteContent),
})
})
app.get('/api/admin-storage-status', requireAdminAuth, async (_req, res) => {
const status = await getStorageStatus()
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) => {
try {
const parsed = await loadSiteContentFile(DATA_FILE)
const siteContent = parsed.siteContent ?? {}
res.json({
seo: siteContent.seo ?? DEFAULT_SEO,
legal: siteContent.legal ?? DEFAULT_LEGAL,
redirects: siteContent.redirects ?? DEFAULT_REDIRECT_RULES,
podcastFeaturedLinks: siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS,
publishState: state.publishState,
updatedAt: parsed.updatedAt ?? null,
})
} catch {
res.json({
seo: DEFAULT_SEO,
legal: DEFAULT_LEGAL,
redirects: DEFAULT_REDIRECT_RULES,
podcastFeaturedLinks: DEFAULT_PODCAST_FEATURED_LINKS,
publishState: state.publishState,
updatedAt: null,
})
}
})
app.put('/api/admin-content-draft', requireAdminAuth, async (req, res) => {
try {
const { siteContent } = req.body ?? {}
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) {
res.status(400).json({ message: 'Invalid payload: siteContent must be an object.' })
return
}
const safeSiteContent = sanitizeSiteContent(siteContent)
const updatedAt = new Date().toISOString()
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
DRAFT_DATA_FILE,
JSON.stringify({ siteContent: safeSiteContent, updatedAt }, null, 2),
'utf8',
)
state.cachedDraftSiteContent = safeSiteContent
state.publishState.draftUpdatedAt = updatedAt
res.json({ ok: true, updatedAt })
} catch (err) {
console.error('[admin-content-draft] persist error:', err)
const reason = err instanceof Error ? err.message : 'Unknown write error'
res.status(500).json({ message: `Failed to persist admin draft content to ${DATA_DIR}: ${reason}` })
}
})
app.post('/api/admin-content/publish', requireAdminAuth, async (_req, res) => {
try {
const source = state.cachedDraftSiteContent
? { siteContent: state.cachedDraftSiteContent, updatedAt: state.publishState.draftUpdatedAt ?? new Date().toISOString() }
: await loadSiteContentFile(DRAFT_DATA_FILE)
const publishedAt = new Date().toISOString()
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
DATA_FILE,
JSON.stringify({ siteContent: source.siteContent, updatedAt: publishedAt }, null, 2),
'utf8',
)
state.cachedSiteContent = source.siteContent
state.publishState.publishedAt = publishedAt
if (state.draftQuestions !== null) {
state.questions = state.draftQuestions.slice(0, MAX_QUESTIONS)
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
QUESTIONS_FILE,
JSON.stringify({ questions: state.questions, updatedAt: publishedAt }, null, 2),
'utf8',
)
}
await createBackupSnapshot('post-publish')
res.json({ ok: true, publishedAt })
} catch (err) {
console.error('[admin-content-publish] persist error:', err)
const reason = err instanceof Error ? err.message : 'Unknown write error'
res.status(500).json({ message: `Failed to publish draft content to ${DATA_DIR}: ${reason}` })
}
})
app.put('/api/admin-content', requireAdminAuth, async (req, res) => {
try {
const { siteContent } = req.body ?? {}
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) {
res.status(400).json({ message: 'Invalid payload: siteContent must be an object.' })
return
}
const safeSiteContent = sanitizeSiteContent(siteContent)
const updatedAt = new Date().toISOString()
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
DATA_FILE,
JSON.stringify({ siteContent: safeSiteContent, updatedAt }, null, 2),
'utf8',
)
state.cachedSiteContent = safeSiteContent
state.publishState.publishedAt = updatedAt
res.json({ ok: true })
} catch {
res.status(500).json({ message: 'Failed to persist admin content.' })
}
})
app.get('/api/admin-ops/status', requireAdminAuth, (_req, res) => {
res.json({
buildCommit: process.env.BUILD_COMMIT ?? null,
buildNumber: process.env.BUILD_NUMBER ?? null,
deployedAt: process.env.DEPLOYED_AT ?? null,
cachePurge: state.lastCachePurgeStatus,
deployHook: state.lastDeployHookStatus,
})
})
app.post('/api/admin-ops/purge-cache', requireAdminAuth, async (_req, res) => {
const result = await invokeWebhook(process.env.CACHE_PURGE_WEBHOOK_URL ?? '', 'cache-purge')
state.lastCachePurgeStatus = { ok: result.ok, at: new Date().toISOString(), error: result.ok ? null : result.message }
if (!result.ok) {
res.status(400).json({ message: result.message })
return
}
res.json({ ok: true, message: result.message })
})
app.post('/api/admin-ops/deploy', requireAdminAuth, async (_req, res) => {
const result = await invokeWebhook(process.env.DEPLOY_WEBHOOK_URL ?? '', 'deploy')
state.lastDeployHookStatus = { ok: result.ok, at: new Date().toISOString(), error: result.ok ? null : result.message }
if (!result.ok) {
res.status(400).json({ message: result.message })
return
}
res.json({ ok: true, message: result.message })
})
app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => {
let adminContent = null
let draftContent = null
try {
const raw = await readFile(DATA_FILE, 'utf8')
adminContent = JSON.parse(raw)
} catch { adminContent = null }
try {
const rawDraft = await readFile(DRAFT_DATA_FILE, 'utf8')
draftContent = JSON.parse(rawDraft)
} catch { draftContent = null }
res.json({
exportedAt: new Date().toISOString(),
adminContent,
draftContent,
publishState: state.publishState,
hitStats: state.hitStats,
visitorStats: state.visitorStats,
contactSubmissions: state.contactSubmissions,
replyTemplates: state.replyTemplates,
replyHistory: state.replyHistory,
})
})
app.post('/api/admin-stats/clear', requireAdminAuth, (_req, res) => {
state.hitStats = { ...EMPTY_HIT_STATS }
state.visitorStats = { ...EMPTY_VISITOR_STATS }
queueHitStatsWrite()
queueVisitorStatsWrite()
createBackupSnapshot('post-clear').catch(() => {})
res.json({ ok: true })
})
app.post('/api/admin-stats/prune', requireAdminAuth, (req, res) => {
const result = pruneStatsByDays(req.body?.days)
queueHitStatsWrite()
queueVisitorStatsWrite()
createBackupSnapshot('post-prune').catch(() => {})
res.json({ ok: true, ...result })
})
app.post('/api/admin-stats/backup', requireAdminAuth, async (_req, res) => {
await createBackupSnapshot('manual')
res.json({ ok: true, backup: state.lastBackupStatus })
})
app.get('/api/admin-stats/backups', requireAdminAuth, async (_req, res) => {
try {
const backups = await listBackupPreviews()
res.json({ backups })
} catch {
res.status(500).json({ message: 'Could not list backups.' })
}
})
app.post('/api/admin-stats/backup-preview', requireAdminAuth, async (req, res) => {
try {
const { filename } = req.body ?? {}
const preview = await readBackupPreview(filename)
res.json({ preview })
} catch (err) {
res.status(400).json({ message: err instanceof Error ? err.message : 'Could not load backup preview.' })
}
})
app.post('/api/admin-stats/restore', requireAdminAuth, async (req, res) => {
try {
const { filename } = req.body ?? {}
await restoreFromBackup(filename)
const backups = await listBackupPreviews()
res.json({ ok: true, restored: filename, backups })
} catch (err) {
res.status(400).json({ message: err instanceof Error ? err.message : 'Restore failed.' })
}
})
}
+406
View File
@@ -0,0 +1,406 @@
import { createHash, randomUUID } from 'node:crypto'
import { requireAdminAuth, isValidAdminSession } from '../auth.js'
import { getClientIp, hasVisitorConsent, setConsentCookie, parseCookies } from '../helpers.js'
import { getStudyUserFromRequest } from '../study-helpers.js'
import {
VISITOR_COOKIE,
MAX_RECENT_VISITS,
} from '../config.js'
import { state } from '../state.js'
import { queueVisitorStatsWrite, queueHitStatsWrite, normalizeMessageType, recordEpisodePlay, recordAnalyticsEvent } from '../data.js'
import {
detectBot,
sanitizeUserAgent,
detectDevice,
sanitizeReferrer,
normalizeHitPath,
isPrivateOrLocalIp,
buildTopLocations,
buildLastNDaysStats,
shouldCountHit,
recordHit,
} from '../study-helpers.js'
import { getStudyCatalog } from '../study-helpers.js'
async function resolveGeo(ip) {
if (!ip || isPrivateOrLocalIp(ip)) {
return { country: 'Local/Unknown', state: 'Local/Unknown', county: 'Local/Unknown', city: 'Local/Unknown' }
}
const cached = state.visitorStats.geoCacheByIp[ip]
if (cached) return cached
const providers = [
async () => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 2500)
try {
const response = await fetch(
`http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,country,regionName,city,district`,
{ signal: controller.signal },
)
if (!response.ok) return null
const data = await response.json()
if (data?.status !== 'success') return null
return { country: data?.country || 'Unknown', state: data?.regionName || 'Unknown', county: data?.district || 'Unknown', city: data?.city || 'Unknown' }
} finally { clearTimeout(timeout) }
},
async () => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 2500)
try {
const response = await fetch(`https://ipwho.is/${encodeURIComponent(ip)}`, { signal: controller.signal })
if (!response.ok) return null
const data = await response.json()
if (!data?.success) return null
return { country: data?.country || 'Unknown', state: data?.region || 'Unknown', county: data?.region || 'Unknown', city: data?.city || 'Unknown' }
} finally { clearTimeout(timeout) }
},
]
for (const provider of providers) {
try {
const geo = await provider()
if (geo) {
state.visitorStats.geoCacheByIp[ip] = geo
queueVisitorStatsWrite()
return geo
}
} catch { /* Try next provider */ }
}
const fallback = { country: 'Unknown', state: 'Unknown', county: 'Unknown', city: 'Unknown' }
state.visitorStats.geoCacheByIp[ip] = fallback
queueVisitorStatsWrite()
return fallback
}
export async function recordVisitor(req, res, overridePath = null, overrideReferrer = null) {
const cookies = parseCookies(req.headers.cookie)
let visitorId = cookies[VISITOR_COOKIE]
if (!visitorId) {
visitorId = randomUUID()
res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax`)
}
const nowIso = new Date().toISOString()
const pathKey = overridePath ? normalizeHitPath(overridePath) : normalizeHitPath(req.path)
const referrer = overrideReferrer !== null ? sanitizeReferrer(overrideReferrer) : sanitizeReferrer(req.get('referer') || req.get('referrer') || '')
const ip = getClientIp(req)
const ua = sanitizeUserAgent(req.get('user-agent'))
const device = detectDevice(ua)
const studyUser = getStudyUserFromRequest(req)
const studyIdentity = studyUser
? { userId: studyUser.id, username: studyUser.username, displayName: studyUser.displayName ?? studyUser.username }
: null
const ipHash = createHash('sha256').update(ip).digest('hex')
const geo = await resolveGeo(ip)
const existingIdByIp = state.visitorStats.ipHashIndex[ipHash]
if (existingIdByIp && existingIdByIp !== visitorId) {
visitorId = existingIdByIp
res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax`)
}
const existingVisitor = state.visitorStats.visitors[visitorId]
const isReturning = Boolean(existingVisitor)
if (!existingVisitor) {
state.visitorStats.uniqueVisitors += 1
state.visitorStats.ipHashIndex[ipHash] = visitorId
} else {
state.visitorStats.returningVisits += 1
}
const nextVisitCount = (existingVisitor?.visitCount ?? 0) + 1
const userAgents = Array.from(new Set([...(existingVisitor?.userAgents ?? []), ua])).slice(0, 5)
const prevHistory = existingVisitor?.pageHistory ?? []
const pageHistory = [...prevHistory, { at: nowIso, path: pathKey, referrer }].slice(-100)
const knownIdentities = existingVisitor?.knownIdentities ?? []
if (studyIdentity && !knownIdentities.some(i => i.userId === studyIdentity.userId)) {
knownIdentities.push(studyIdentity)
}
state.visitorStats.visitors[visitorId] = {
visitorId, ip, ipHash,
firstSeenAt: existingVisitor?.firstSeenAt ?? nowIso,
lastSeenAt: nowIso,
visitCount: nextVisitCount,
lastPath: pathKey,
returningVisitor: isReturning,
location: geo,
userAgents,
device,
pageHistory,
...(knownIdentities.length > 0 ? { knownIdentities } : {}),
}
state.visitorStats.totalVisits += 1
state.visitorStats.firstVisitAt = state.visitorStats.firstVisitAt ?? nowIso
state.visitorStats.lastVisitAt = nowIso
state.visitorStats.recentVisits.unshift({
at: nowIso, visitorId, ip, path: pathKey, referrer, device,
country: geo.country, state: geo.state, county: geo.county, city: geo.city,
returningVisitor: isReturning, visitCount: nextVisitCount,
...(studyIdentity ? { studyUser: studyIdentity } : {}),
})
state.visitorStats.recentVisits = state.visitorStats.recentVisits.slice(0, MAX_RECENT_VISITS)
queueVisitorStatsWrite()
}
export function register(app) {
app.post('/api/analytics-consent', (req, res) => {
const consent = req.body?.consent === true
setConsentCookie(res, consent)
res.json({ ok: true, consent })
})
app.post('/api/analytics/play', (req, res) => {
if (isValidAdminSession(req)) { res.json({ ok: false, reason: 'admin' }); return }
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 }
recordEpisodePlay(title)
res.json({ ok: true })
})
app.post('/api/analytics/event', (req, res) => {
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 type = typeof req.body?.type === 'string' ? req.body.type : ''
const ALLOWED_TYPES = ['scroll_depth', 'time_on_page', 'outbound_click', 'link_click', 'utm', 'search_query', 'not_found', 'audio_pause', 'audio_completion', 'audio_listen_time']
if (!ALLOWED_TYPES.includes(type)) { res.status(400).json({ ok: false, reason: 'invalid-type' }); return }
recordAnalyticsEvent(type, req.body)
res.json({ ok: true })
})
app.post('/api/analytics/pageview', async (req, res) => {
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 rawPath = typeof req.body?.path === 'string' ? req.body.path : '/'
const rawReferrer = typeof req.body?.referrer === 'string' ? req.body.referrer : ''
recordHit(rawPath, false)
queueHitStatsWrite()
await recordVisitor(req, res, rawPath, rawReferrer)
res.json({ ok: true })
})
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 rangeDays = rangeParam === '7d' ? 7 : rangeParam === '90d' ? 90 : 30
const rangeLabel = rangeParam === '7d' ? '7d' : rangeParam === '90d' ? '90d' : '30d'
// Compute a cutoff date string (YYYY-MM-DD) for filtering daily buckets
const cutoffDate = (() => {
const d = new Date()
d.setDate(d.getDate() - (rangeDays - 1))
return d.toISOString().slice(0, 10)
})()
// Filter byDayReal/byDayBot keys to only those within the range
const filteredDayKeys = Object.keys(state.hitStats.byDayReal ?? {}).filter(day => day >= cutoffDate)
// Aggregate hits for the range
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 rangeTotalHits = rangeRealHits + rangeBotHits
// Filter per-path stats by range — approximate using recent visitor rows scoped to range
const rangeVisitorRows = state.visitorStats.recentVisits.filter(row => {
if (!row.visitedAt) return true // include if no timestamp
return row.visitedAt >= cutoffDate
})
const rangePathCountsReal = {}
const rangePathCountsBot = {}
for (const row of rangeVisitorRows) {
const p = row.path ?? '/'
if (row.isBot) rangePathCountsBot[p] = (rangePathCountsBot[p] ?? 0) + 1
else rangePathCountsReal[p] = (rangePathCountsReal[p] ?? 0) + 1
}
const topPathsReal = Object.entries(rangePathCountsReal).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([path, hits]) => ({ path, hits }))
const topPathsBot = Object.entries(rangePathCountsBot).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([path, hits]) => ({ path, hits }))
const topPaths = [...topPathsReal, ...topPathsBot].reduce((acc, { path, hits }) => {
const existing = acc.find(x => x.path === path)
if (existing) existing.hits += hits; else acc.push({ path, hits })
return acc
}, []).sort((a, b) => b.hits - a.hits).slice(0, 10)
const last7Days = buildLastNDaysStats(7)
const last7DaysReal = last7Days.map(item => ({ day: item.day, hits: state.hitStats.byDayReal?.[item.day] ?? 0 }))
const last7DaysBot = last7Days.map(item => ({ day: item.day, hits: state.hitStats.byDayBot?.[item.day] ?? 0 }))
const last30Days = buildLastNDaysStats(30)
const last30DaysTotal = last30Days.reduce((sum, item) => sum + item.hits, 0)
const last30DaysRealTotal = last30Days.reduce((sum, item) => sum + (state.hitStats.byDayReal?.[item.day] ?? 0), 0)
const last30DaysBotTotal = last30Days.reduce((sum, item) => sum + (state.hitStats.byDayBot?.[item.day] ?? 0), 0)
const botReasons = Object.entries(state.hitStats.botReasons ?? {})
.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)
const recentVisitorRows = rangeVisitorRows.slice(0, 100).map(row => {
const fullVisitor = state.visitorStats.visitors[row.visitorId]
return { ...row, pageHistory: fullVisitor?.pageHistory ?? [] }
})
const enrollmentCountsBySlug = {}
for (const user of state.studyUsers) {
const userEnrollments = Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : []
for (const studySlug of userEnrollments) {
enrollmentCountsBySlug[studySlug] = (enrollmentCountsBySlug[studySlug] ?? 0) + 1
}
}
const enrollmentsByStudy = getStudyCatalog()
.map(study => ({ slug: study.slug, title: study.title, count: enrollmentCountsBySlug[study.slug] ?? 0 }))
.sort((a, b) => b.count - a.count)
const studyCatalogBySlug = new Map(getStudyCatalog().map(study => [study.slug, study]))
const users = state.studyUsers
.map(user => {
const enrolledStudySlugs = Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : []
const enrolledStudies = enrolledStudySlugs.map(slug => {
const study = studyCatalogBySlug.get(slug)
return study ? { slug: study.slug, title: study.title } : null
}).filter(Boolean)
return { id: user.id, username: user.username, displayName: user.displayName ?? '', enrolledStudies }
})
.sort((a, b) => {
if (b.enrolledStudies.length !== a.enrolledStudies.length) return b.enrolledStudies.length - a.enrolledStudies.length
return a.username.localeCompare(b.username)
})
const enrolledUsers = state.studyUsers.filter(user => (user.enrolledStudySlugs?.length ?? 0) > 0).length
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 funnelFirstVisit = state.studyUsers.filter(u => u.firstVisitAt).length
const funnelFirstCompletion = state.studyUsers.filter(u => u.firstCompletionAt).length
res.json({
totalHits: state.hitStats.totalHits,
realHits: rangeRealHits,
botHits: rangeBotHits,
rangeTotalHits,
rangeLabel,
rangeDays,
// All-time totals for reference
allTimeRealHits: state.hitStats.realHits ?? 0,
allTimeBotHits: state.hitStats.botHits ?? 0,
allTimeTotalHits: state.hitStats.totalHits,
firstHitAt: state.hitStats.firstHitAt,
lastHitAt: state.hitStats.lastHitAt,
topPaths, topPathsReal, topPathsBot,
last7Days, last7DaysReal, last7DaysBot,
last30DaysTotal, last30DaysRealTotal, last30DaysBotTotal,
botReasons,
visitors: {
totalVisits: state.visitorStats.totalVisits,
uniqueVisitors: state.visitorStats.uniqueVisitors,
returningVisits: state.visitorStats.returningVisits,
firstVisitAt: state.visitorStats.firstVisitAt,
lastVisitAt: state.visitorStats.lastVisitAt,
topCountries: buildTopLocations(allRangeRows, 'country'),
topStates: buildTopLocations(allRangeRows, 'state'),
topCounties: buildTopLocations(allRangeRows, 'county'),
topCities: buildTopLocations(allRangeRows, 'city'),
deviceBreakdown: (() => {
const counts = { mobile: 0, desktop: 0, tablet: 0, unknown: 0 }
for (const row of allRangeRows) {
const d = row.device ?? 'unknown'
counts[d] = (counts[d] ?? 0) + 1
}
return counts
})(),
topReferrers: (() => {
const counts = {}
for (const row of allRangeRows) {
if (!row.referrer) continue
counts[row.referrer] = (counts[row.referrer] ?? 0) + 1
}
return Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([referrer, count]) => ({ referrer, count }))
})(),
last30DaysReal: buildLastNDaysStats(30).map(item => ({ day: item.day, hits: state.hitStats.byDayReal?.[item.day] ?? 0 })),
recentVisits: recentVisitorRows,
},
writeStatus: {
hitStats: state.lastHitStatsWrite,
visitorStats: state.lastVisitorStatsWrite,
backups: state.lastBackupStatus,
cachePurge: state.lastCachePurgeStatus,
deployHook: state.lastDeployHookStatus,
},
contactTotals: {
totalSubmissions: state.contactSubmissions.length,
totalQuestions: state.contactSubmissions.filter(entry => normalizeMessageType(entry?.messageType) === 'question').length,
},
studyEnrollment: {
totalUsers: state.studyUsers.length,
enrolledUsers,
totalEnrollments,
enrollmentsByStudy,
users,
funnel: { signups: funnelSignups, firstVisit: funnelFirstVisit, firstCompletion: funnelFirstCompletion },
},
episodePlays: Object.entries(state.episodePlays)
.map(([title, data]) => ({
title,
total: data.total ?? 0,
byDay: data.byDay ?? {},
last30Days: buildLastNDaysStats(30).map(item => ({ day: item.day, plays: data.byDay?.[item.day] ?? 0 })),
}))
.sort((a, b) => b.total - a.total),
engagement: {
scrollDepth: Object.entries(state.analyticsEvents.scrollDepth ?? {})
.map(([path, marks]) => ({ path, ...marks }))
.sort((a, b) => (b[90] ?? 0) - (a[90] ?? 0))
.slice(0, 20),
timeOnPage: Object.entries(state.analyticsEvents.timeOnPage ?? {})
.map(([path, { totalSeconds, count }]) => ({ path, avgSeconds: count > 0 ? Math.round(totalSeconds / count) : 0, count }))
.sort((a, b) => b.avgSeconds - a.avgSeconds)
.slice(0, 20),
topOutboundClicks: Object.entries(state.analyticsEvents.outboundClicks ?? {})
.map(([url, count]) => ({ url, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 20),
topLinkClicks: Object.entries(state.analyticsEvents.linkClicks ?? {})
.map(([url, { count, internal }]) => ({ url, count, internal }))
.sort((a, b) => b.count - a.count)
.slice(0, 50),
topUTMSources: Object.entries(state.analyticsEvents.utmSources ?? {})
.map(([source, count]) => ({ source, count }))
.sort((a, b) => b.count - a.count),
topSearchQueries: Object.entries(state.analyticsEvents.searchQueries ?? {})
.map(([query, count]) => ({ query, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 30),
top404s: Object.entries(state.analyticsEvents.notFound ?? {})
.map(([path, count]) => ({ path, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 20),
audioEvents: Object.entries(state.analyticsEvents.audioEvents ?? {})
.map(([title, data]) => ({ title, ...data }))
.sort((a, b) => b.completions - a.completions),
},
})
})
}
+77
View File
@@ -0,0 +1,77 @@
const passageCache = new Map()
const BOLLS_BOOK_MAP = {
genesis: 1, exodus: 2, leviticus: 3, numbers: 4, deuteronomy: 5,
joshua: 6, judges: 7, ruth: 8, '1samuel': 9, '2samuel': 10,
'1kings': 11, '2kings': 12, '1chronicles': 13, '2chronicles': 14,
ezra: 15, nehemiah: 16, esther: 17, job: 18, psalms: 19, proverbs: 20,
ecclesiastes: 21, songofsolomon: 22, isaiah: 23, jeremiah: 24,
lamentations: 25, ezekiel: 26, daniel: 27, hosea: 28, joel: 29,
amos: 30, obadiah: 31, jonah: 32, micah: 33, nahum: 34, habakkuk: 35,
zephaniah: 36, haggai: 37, zechariah: 38, malachi: 39,
matthew: 40, mark: 41, luke: 42, john: 43, acts: 44,
romans: 45, '1corinthians': 46, '2corinthians': 47, galatians: 48,
ephesians: 49, philippians: 50, colossians: 51, '1thessalonians': 52,
'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,
}
function parseReference(reference) {
const stripped = reference.replace(/^[a-z]+/i, '')
const match = stripped.match(/^(\d+):(\d+)(?:-(\d+))?$/)
if (!match) return null
return {
chapter: parseInt(match[1], 10),
verseStart: parseInt(match[2], 10),
verseEnd: match[3] ? parseInt(match[3], 10) : parseInt(match[2], 10),
}
}
export function register(app) {
app.get('/api/bible-passage', async (req, res) => {
const book = String(req.query.book ?? '').trim().toLowerCase().replace(/[\s-]/g, '')
const reference = String(req.query.reference ?? '').trim()
const bookNum = BOLLS_BOOK_MAP[book]
if (!bookNum) {
res.status(400).json({ message: 'Unknown book.' })
return
}
const parsed = parseReference(reference)
if (!parsed) {
res.status(400).json({ message: 'Invalid reference format. Expected format: chapter:verseStart-verseEnd' })
return
}
const cacheKey = `BSB:${bookNum}:${parsed.chapter}:${parsed.verseStart}:${parsed.verseEnd}`
if (passageCache.has(cacheKey)) {
res.json({ text: passageCache.get(cacheKey) })
return
}
try {
const verseNums = []
for (let v = parsed.verseStart; v <= parsed.verseEnd; v++) verseNums.push(v)
const results = await Promise.all(
verseNums.map(v =>
fetch(`https://bolls.life/get-verse/BSB/${bookNum}/${parsed.chapter}/${v}/`, { signal: AbortSignal.timeout(8000) })
.then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)))
)
)
if (results.length === 0) {
res.status(404).json({ message: 'Passage not found.' })
return
}
const text = results.map(v => `v. ${v.verse} ${String(v.text).trim()}`).join('\n\n')
passageCache.set(cacheKey, text)
res.json({ text })
} catch {
res.status(502).json({ message: 'Unable to fetch passage right now.' })
}
})
}
+192
View File
@@ -0,0 +1,192 @@
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 })
})
}
+983
View File
@@ -0,0 +1,983 @@
import { randomUUID } from 'node:crypto'
import { Resend } from 'resend'
import { requireAdminAuth } from '../auth.js'
import { escapeHtml, splitName } from '../helpers.js'
import {
MAX_CONTACT_SUBMISSIONS,
MAX_QUESTIONS,
USE_RESEND_AUTOMATION_WELCOME,
DEFAULT_SEO,
ADMIN_REPLY_FROM,
ADMIN_REPLY_FROM_OPTIONS,
} from '../config.js'
import { state } from '../state.js'
import {
queueContactSubmissionsWrite,
queueQuestionsWrite,
queueDraftQuestionsWrite,
queueReplyTemplatesWrite,
queueReplyHistoryWrite,
queueEmailSettingsWrite,
normalizeContactEmailStatus,
normalizeMessageType,
sanitizeReplyTemplates,
sanitizeReplyHistory,
appendAuditEntry,
queueWebhooksWrite,
fireWebhooks,
} from '../data.js'
import {
noteContactEmailCooldown,
extractTagValue,
mapResendEventToStatus,
extractResendMessageId,
} from '../study-helpers.js'
import {
getResendFromAddress,
getResendReplyToAddress,
getResendInboxAddress,
getAddressDomain,
buildContactWelcomeEmailTemplate,
buildContactAdminNotificationTemplate,
buildAdminReplyTemplate,
sendResendEmailWithRetry,
syncContactToResend,
} from '../email.js'
// RFC 5322 msg-id: "<" printable-ASCII-no-whitespace ">"
const MESSAGE_ID_RE = /^<[\x21-\x7E]+>$/
function upsertContactEmailStatus(submissionId, stream, patch) {
if (!submissionId || typeof submissionId !== 'string') return
if (!stream || typeof stream !== 'string') return
const at = typeof patch?.lastEventAt === 'string' ? patch.lastEventAt : new Date().toISOString()
let updated = false
state.contactSubmissions = state.contactSubmissions.map(submission => {
if (submission.id !== submissionId) return submission
const next = normalizeContactEmailStatus(submission.emailStatus, submission.subscribe === true)
const current = next[stream] ?? { status: 'pending', lastEventAt: null, lastEventType: null, resendEmailId: null, error: null }
next[stream] = { ...current, ...patch, lastEventAt: at }
updated = true
return { ...submission, emailStatus: next }
})
if (updated) queueContactSubmissionsWrite()
}
function registerResendMessageForSubmission(submissionId, stream, sendResult) {
const resendMessageId = extractResendMessageId(sendResult)
if (!resendMessageId || !submissionId || !stream) return
state.resendEmailSubmissionIndex.set(resendMessageId, { submissionId, stream })
// Trim the index when it grows large; oldest entries are least likely to receive webhooks
if (state.resendEmailSubmissionIndex.size > 2000) {
const firstKey = state.resendEmailSubmissionIndex.keys().next().value
state.resendEmailSubmissionIndex.delete(firstKey)
}
upsertContactEmailStatus(submissionId, stream, { resendEmailId: resendMessageId })
}
function shouldSendWelcomeEmail({ subscribe }) {
return subscribe === true
}
const contactHits = new Map()
function contactRateLimit(req, res, next) {
const ip = req.ip ?? 'unknown'
const now = Date.now()
const windowMs = 10 * 60 * 1000
const entry = contactHits.get(ip) ?? { count: 0, start: now }
if (now - entry.start > windowMs) { entry.count = 0; entry.start = now }
entry.count += 1
contactHits.set(ip, entry)
// Prune stale entries to prevent unbounded growth
if (contactHits.size > 5000) {
const cutoff = now - windowMs
for (const [k, v] of contactHits) { if (v.start < cutoff) contactHits.delete(k) }
}
if (entry.count > 5) {
res.status(429).json({ message: 'Too many messages. Please wait a few minutes.' })
return
}
next()
}
export function register(app) {
app.post('/api/contact', contactRateLimit, async (req, res) => {
try {
const { firstName, lastName, email, message, messageType, subscribe, notifyOnAnswer, _honey } = req.body ?? {}
if (_honey) { res.json({ ok: true }); 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 !== undefined && lastName !== null && (typeof lastName !== 'string' || lastName.trim().length > 100)) {
res.status(400).json({ message: 'Last name is too long.' }); 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
}
if (!message || typeof message !== 'string' || message.trim().length < 5 || message.trim().length > 3000) {
res.status(400).json({ message: 'Message must be between 5 and 3000 characters.' }); return
}
if (!process.env.RESEND_API_KEY) {
console.error('[contact] RESEND_API_KEY env var not set')
res.status(503).json({ message: 'The contact form is not yet configured on the server.' }); return
}
const trimmedName = [firstName.trim(), typeof lastName === 'string' ? lastName.trim() : ''].filter(Boolean).join(' ')
const trimmedEmail = email.trim()
const trimmedMessage = message.trim()
const normalizedMessageType = normalizeMessageType(messageType)
const cooldown = noteContactEmailCooldown(trimmedEmail)
if (!cooldown.ok) {
const retryAfterSeconds = Math.max(1, Math.ceil(cooldown.retryAfterMs / 1000))
res.status(429).json({ message: `Please wait ${retryAfterSeconds}s before sending another message from this email.` }); return
}
const submittedAt = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })
const shouldSendWelcome = shouldSendWelcomeEmail({ subscribe })
const wantsWelcome = subscribe === true
const submission = {
id: randomUUID(),
submittedAt: new Date().toISOString(),
name: trimmedName,
email: trimmedEmail,
message: trimmedMessage,
messageType: normalizedMessageType,
subscribe: wantsWelcome,
archived: false,
emailStatus: normalizeContactEmailStatus(null, wantsWelcome),
}
state.contactSubmissions.unshift(submission)
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
queueContactSubmissionsWrite()
fireWebhooks('contact.new', { name: trimmedName, email: trimmedEmail, message: trimmedMessage, messageType: normalizedMessageType, submittedAt: submission.submittedAt })
if (normalizedMessageType === 'question') {
const question = {
id: randomUUID(),
submittedAt: new Date().toISOString(),
firstName: splitName(trimmedName).firstName,
email: trimmedEmail,
question: trimmedMessage,
answer: '',
answeredAt: null,
isApproved: false,
approvedAt: null,
notifyOnAnswer: notifyOnAnswer === true,
}
state.questions.unshift(question)
state.questions = state.questions.slice(0, MAX_QUESTIONS)
if (state.draftQuestions !== null) {
state.draftQuestions.unshift(question)
state.draftQuestions = state.draftQuestions.slice(0, MAX_QUESTIONS)
queueDraftQuestionsWrite()
}
queueQuestionsWrite()
}
const resend = new Resend(process.env.RESEND_API_KEY)
const adminInbox = getResendInboxAddress()
const replyToAddress = getResendReplyToAddress()
const fromAddress = getResendFromAddress()
const safeMessageTypeTag = normalizedMessageType.replace(/[^a-z0-9_-]/gi, '-').toLowerCase()
const adminTemplate = buildContactAdminNotificationTemplate({ normalizedMessageType, trimmedName, trimmedEmail, submittedAt, trimmedMessage })
let welcomeSent = false
if (subscribe === true) {
await syncContactToResend(trimmedName, trimmedEmail)
}
if (shouldSendWelcome && !USE_RESEND_AUTOMATION_WELCOME) {
const greetingName = splitName(trimmedName).firstName?.trim() ?? ''
let publishedSiteContent = state.cachedSiteContent
if (!publishedSiteContent) {
try {
const { loadSiteContentFile } = await import('../data.js')
const { DATA_FILE } = await import('../config.js')
const published = await loadSiteContentFile(DATA_FILE)
publishedSiteContent = published?.siteContent ?? null
} catch { publishedSiteContent = null }
}
const emailConfig = publishedSiteContent ?? {}
const welcomeBaseUrl = typeof emailConfig?.seo?.canonicalUrl === 'string' && emailConfig.seo.canonicalUrl.trim()
? emailConfig.seo.canonicalUrl.trim()
: DEFAULT_SEO.canonicalUrl
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 { buildAbsoluteUrl } = await import('../helpers.js')
const welcomeTemplate = buildContactWelcomeEmailTemplate({
greetingName,
welcomeGreetingPrefix,
welcomeIntro: emailConfig.welcomeEmailIntro?.trim() || '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.',
welcomeCurrentSeries: emailConfig.welcomeEmailCurrentSeries?.trim() || "Right now we're working through the book of Titus - a short letter packed with practical wisdom about grace, godliness, and what the Christian life looks like when it's rooted in sound doctrine.",
welcomeStartHereTitle: emailConfig.welcomeEmailStartHereTitle?.trim() || 'Episode 1 - Introduction to Titus',
welcomeStartHereSummary: emailConfig.welcomeEmailStartHereSummary?.trim() || 'Who wrote it, who received it, and why it still matters.',
welcomeExpect1: emailConfig.welcomeEmailWhatToExpect1?.trim() || 'Verse-by-verse teaching - we go slow and let the text speak for itself.',
welcomeExpect2: emailConfig.welcomeEmailWhatToExpect2?.trim() || 'Greek word studies - the kind that open up meaning without being a lecture.',
welcomeExpect3: emailConfig.welcomeEmailWhatToExpect3?.trim() || 'New episodes + study notes delivered right to your inbox.',
welcomeScripture: emailConfig.welcomeEmailScripture?.trim() || 'For the grace of God has appeared, bringing salvation to all people.',
welcomeScriptureRef: emailConfig.welcomeEmailScriptureRef?.trim() || 'Titus 2:11 - BSB',
welcomeSignoff: emailConfig.welcomeEmailSignoff?.trim() || 'Grace and peace,\nNate',
welcomeSpotifyUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_SPOTIFY_URL ?? emailConfig.welcomeEmailSpotifyUrl ?? '/spotify'),
welcomeAppleUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_APPLE_URL ?? emailConfig.welcomeEmailAppleUrl ?? '/apple'),
welcomeAmazonUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_AMAZON_URL ?? emailConfig.welcomeEmailAmazonUrl ?? '/amazon'),
welcomeWebsiteUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_WEBSITE_URL ?? emailConfig.welcomeEmailWebsiteUrl ?? '/'),
welcomeEpisodeUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_EPISODE_URL ?? emailConfig.welcomeEmailStartHereUrl ?? '/start-here'),
welcomeImageUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_IMAGE_URL ?? emailConfig.welcomeEmailImageUrl ?? '/images/podcast-art.jpeg'),
welcomeSpotifyBtnLabel: emailConfig.welcomeEmailSpotifyBtnLabel?.trim() || 'Listen on Spotify',
welcomeAppleBtnLabel: emailConfig.welcomeEmailAppleBtnLabel?.trim() || 'Apple Podcasts',
welcomeStartHereLinkLabel: emailConfig.welcomeEmailStartHereLinkLabel?.trim() || 'Open Start Here page',
})
try {
const welcomeSendResult = await sendResendEmailWithRetry({
resend,
context: 'contact-welcome',
payload: {
from: fromAddress,
to: [trimmedEmail],
replyTo: replyToAddress,
subject: welcomeSubject,
tags: [
{ name: 'flow', value: 'contact-welcome' },
{ name: 'message_type', value: safeMessageTypeTag },
{ name: 'submission_id', value: submission.id },
],
headers: {
'List-Unsubscribe': `<mailto:${replyToAddress}?subject=Unsubscribe>`,
'X-Contact-Submission-Id': submission.id,
},
text: welcomeTemplate.text,
html: welcomeTemplate.html,
},
})
registerResendMessageForSubmission(submission.id, 'welcome', welcomeSendResult)
upsertContactEmailStatus(submission.id, 'welcome', { status: 'sent', lastEventType: 'email.sent', error: null })
welcomeSent = true
} catch (welcomeErr) {
upsertContactEmailStatus(submission.id, 'welcome', {
status: 'failed',
lastEventType: 'email.failed',
error: String(welcomeErr?.message ?? welcomeErr ?? 'unknown error').slice(0, 600),
})
console.error('[contact] welcome email failed:', welcomeErr)
// Submission is already saved — don't 500 the user; fall through to admin notification.
}
} else if (shouldSendWelcome && USE_RESEND_AUTOMATION_WELCOME) {
upsertContactEmailStatus(submission.id, 'welcome', { status: 'automation-enabled', lastEventType: 'email.automation.enabled', error: null })
}
try {
const adminSendResult = await sendResendEmailWithRetry({
resend,
context: 'contact-admin-notification',
payload: {
from: fromAddress,
to: [adminInbox],
replyTo: trimmedEmail,
subject: adminTemplate.subject,
tags: [
{ name: 'flow', value: 'contact-admin' },
{ name: 'message_type', value: safeMessageTypeTag },
{ name: 'submission_id', value: submission.id },
],
headers: { 'X-Contact-Submission-Id': submission.id },
text: adminTemplate.text,
html: adminTemplate.html,
},
})
registerResendMessageForSubmission(submission.id, 'adminNotification', adminSendResult)
upsertContactEmailStatus(submission.id, 'adminNotification', { status: 'sent', lastEventType: 'email.sent', error: null })
} catch (adminSendErr) {
upsertContactEmailStatus(submission.id, 'adminNotification', {
status: 'failed',
lastEventType: 'email.failed',
error: String(adminSendErr?.message ?? adminSendErr ?? 'unknown error').slice(0, 600),
})
console.error('[contact] admin notification email failed:', adminSendErr)
// Submission is already saved — don't 500 the user.
}
res.json({ ok: true, welcomeSent, welcomeHandledByAutomation: shouldSendWelcome && USE_RESEND_AUTOMATION_WELCOME })
} catch (err) {
console.error('[contact] send error:', err)
res.status(500).json({ message: 'Failed to send your message. Please try again or email us directly.' })
}
})
app.post('/api/resend/webhook', (req, res) => {
const expectedToken = typeof process.env.RESEND_WEBHOOK_TOKEN === 'string' ? process.env.RESEND_WEBHOOK_TOKEN.trim() : ''
if (!expectedToken) {
res.status(503).json({ message: 'Webhook token is not configured.' }); return
}
const providedToken = (req.get('x-webhook-token') || '').trim()
|| (req.get('x-resend-webhook-token') || '').trim()
|| String(req.query?.token || '').trim()
|| (req.get('authorization') || '').replace(/^Bearer\s+/i, '').trim()
if (!providedToken || providedToken !== expectedToken) {
res.status(401).json({ message: 'Unauthorized webhook.' }); return
}
const body = req.body && typeof req.body === 'object' ? req.body : {}
const eventType = typeof body.type === 'string' ? body.type.trim() : ''
const data = body.data && typeof body.data === 'object' ? body.data : {}
const tags = Array.isArray(data.tags) ? data.tags : []
const resendMessageId = (
typeof data.email_id === 'string' && data.email_id.trim()
? data.email_id.trim()
: (typeof data.emailId === 'string' && data.emailId.trim()
? data.emailId.trim()
: (typeof data.id === 'string' && data.id.trim() ? data.id.trim() : ''))
)
const indexed = resendMessageId ? state.resendEmailSubmissionIndex.get(resendMessageId) : null
const taggedSubmissionId = extractTagValue(tags, 'submission_id')
const submissionId = indexed?.submissionId || taggedSubmissionId
const flow = extractTagValue(tags, 'flow')
const stream = indexed?.stream
|| (flow === 'contact-welcome' ? 'welcome' : '')
|| (flow === 'contact-admin' ? 'adminNotification' : '')
|| (flow === 'admin-reply' ? 'adminReply' : '')
if (!submissionId || !stream) {
res.json({ ok: true, ignored: true }); return
}
upsertContactEmailStatus(submissionId, stream, {
status: mapResendEventToStatus(eventType),
lastEventType: eventType || 'webhook.event',
resendEmailId: resendMessageId || null,
error: typeof data?.message === 'string' ? data.message.slice(0, 600) : null,
})
res.json({ ok: true })
})
app.get('/api/admin-contact-email-health', requireAdminAuth, (_req, res) => {
const fromAddress = getResendFromAddress()
const replyToAddress = getResendReplyToAddress()
const fromDomain = getAddressDomain(fromAddress)
const replyDomain = getAddressDomain(replyToAddress)
const warnings = []
if (!process.env.RESEND_API_KEY) warnings.push('RESEND_API_KEY is missing.')
if (!fromDomain) warnings.push('RESEND_FROM is missing or invalid.')
if (fromDomain.endsWith('resend.dev')) warnings.push('RESEND_FROM uses resend.dev. Prefer a verified custom domain.')
if (fromDomain && replyDomain && fromDomain !== replyDomain) warnings.push('Sender and reply-to domains are different.')
if (!process.env.RESEND_WEBHOOK_TOKEN) warnings.push('RESEND_WEBHOOK_TOKEN is not configured.')
warnings.push('Verify SPF, DKIM, and DMARC for the sender domain.')
const recent = state.contactSubmissions.slice(0, 300)
const failed = recent.filter(item => {
const status = normalizeContactEmailStatus(item.emailStatus, item.subscribe === true)
return ['failed', 'bounced', 'complained'].includes(status.welcome.status)
|| ['failed', 'bounced', 'complained'].includes(status.adminNotification.status)
|| ['failed', 'bounced', 'complained'].includes(status.adminReply.status)
}).length
res.json({
resendApiConfigured: Boolean(process.env.RESEND_API_KEY),
webhookConfigured: Boolean(process.env.RESEND_WEBHOOK_TOKEN),
fromAddress,
replyToAddress,
fromDomain,
replyDomain,
warnings,
recentSubmissionFailures: failed,
trackedSubmissions: recent.length,
})
})
app.get('/api/admin-contact-submissions', requireAdminAuth, (_req, res) => {
res.json({ submissions: state.contactSubmissions.slice(0, 300) })
})
app.post('/api/admin-contact-submissions/add', requireAdminAuth, (req, res) => {
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''
const email = typeof req.body?.email === 'string' ? req.body.email.trim() : ''
const notes = typeof req.body?.notes === 'string' ? req.body.notes.trim().slice(0, 2000) : ''
if (!name && !email) {
res.status(400).json({ message: 'Name or email is required.' }); return
}
if (email && !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email)) {
res.status(400).json({ message: 'Invalid email address.' }); return
}
const submission = {
id: randomUUID(),
submittedAt: new Date().toISOString(),
name: name.slice(0, 200),
email,
message: '',
messageType: 'general',
subscribe: false,
archived: false,
source: 'manual',
notes,
emailStatus: normalizeContactEmailStatus(null, false),
}
state.contactSubmissions.unshift(submission)
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
queueContactSubmissionsWrite()
res.json({ ok: true, submission })
})
app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
const { id } = req.params
if (typeof id !== 'string' || !id.trim()) {
res.status(400).json({ message: 'Invalid submission id.' }); return
}
const patch = {}
if (typeof req.body?.archived === 'boolean') patch.archived = req.body.archived
if (typeof req.body?.starred === 'boolean') patch.starred = req.body.starred
if (typeof req.body?.name === 'string') patch.name = req.body.name.trim().slice(0, 200)
if (typeof req.body?.notes === 'string') patch.notes = req.body.notes.trim().slice(0, 2000)
if (Array.isArray(req.body?.tags)) {
patch.tags = [...new Set(req.body.tags.filter(t => typeof t === 'string' && t.trim()).map(t => t.trim().slice(0, 50)))].slice(0, 20)
}
if ('snoozedUntil' in (req.body ?? {})) {
const v = req.body.snoozedUntil
patch.snoozedUntil = v === null ? null : (typeof v === 'string' && !isNaN(Date.parse(v)) ? v : undefined)
if (patch.snoozedUntil === undefined) delete patch.snoozedUntil
}
let found = false
state.contactSubmissions = state.contactSubmissions.map(item => {
if (item.id !== id) return item
found = true
return { ...item, ...patch }
})
if (!found) {
res.status(404).json({ message: 'Submission not found.' }); return
}
queueContactSubmissionsWrite()
res.json({ ok: true })
})
app.delete('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
const { id } = req.params
if (typeof id !== 'string' || !id.trim()) {
res.status(400).json({ message: 'Invalid submission id.' }); return
}
const startLength = state.contactSubmissions.length
state.contactSubmissions = state.contactSubmissions.filter(item => item.id !== id)
if (state.contactSubmissions.length === startLength) {
res.status(404).json({ message: 'Submission not found.' }); return
}
queueContactSubmissionsWrite()
appendAuditEntry('submission-deleted', `id: ${id}`)
res.json({ ok: true })
})
app.post('/api/admin-contact-submissions/bulk', requireAdminAuth, (req, res) => {
const { ids, action } = req.body ?? {}
if (!Array.isArray(ids) || !['archive', 'unarchive', 'delete', 'star', 'unstar'].includes(action)) {
res.status(400).json({ message: 'Invalid bulk action.' }); return
}
const idSet = new Set(ids.filter(id => typeof id === 'string'))
if (idSet.size === 0) { res.json({ ok: true, affected: 0 }); return }
let affected = 0
if (action === 'delete') {
const before = state.contactSubmissions.length
state.contactSubmissions = state.contactSubmissions.filter(s => !idSet.has(s.id))
affected = before - state.contactSubmissions.length
} else {
const patch = action === 'archive' ? { archived: true }
: action === 'unarchive' ? { archived: false }
: action === 'star' ? { starred: true }
: { starred: false }
state.contactSubmissions = state.contactSubmissions.map(s => {
if (!idSet.has(s.id)) return s
affected++
return { ...s, ...patch }
})
}
queueContactSubmissionsWrite()
res.json({ ok: true, affected })
})
app.post('/api/admin-contacts/merge', requireAdminAuth, (req, res) => {
const keepEmail = typeof req.body?.keepEmail === 'string' ? req.body.keepEmail.trim().toLowerCase() : ''
const mergeEmail = typeof req.body?.mergeEmail === 'string' ? req.body.mergeEmail.trim().toLowerCase() : ''
if (!keepEmail || !mergeEmail || keepEmail === mergeEmail) {
res.status(400).json({ message: 'keepEmail and mergeEmail must be different non-empty addresses.' }); return
}
let affected = 0
state.contactSubmissions = state.contactSubmissions.map(s => {
if ((s.email ?? '').trim().toLowerCase() !== mergeEmail) return s
affected++
return { ...s, email: keepEmail }
})
queueContactSubmissionsWrite()
appendAuditEntry('contacts-merged', `${mergeEmail}${keepEmail} (${affected} submission${affected !== 1 ? 's' : ''})`)
res.json({ ok: true, affected })
})
app.post('/api/admin-contacts/import', requireAdminAuth, (req, res) => {
const rows = req.body?.rows
if (!Array.isArray(rows) || rows.length === 0) {
res.status(400).json({ message: 'rows must be a non-empty array.' }); return
}
const EMAIL_RE = /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/
let created = 0
let skipped = 0
const toAdd = []
for (const row of rows.slice(0, 1000)) {
const name = typeof row.name === 'string' ? row.name.trim().slice(0, 200) : ''
const email = typeof row.email === 'string' ? row.email.trim().toLowerCase().slice(0, 320) : ''
const notes = typeof row.notes === 'string' ? row.notes.trim().slice(0, 2000) : ''
const tags = Array.isArray(row.tags)
? row.tags.filter(t => typeof t === 'string' && t.trim()).map(t => t.trim().slice(0, 50)).slice(0, 20)
: (typeof row.tags === 'string' ? row.tags.split(';').map(t => t.trim()).filter(Boolean).slice(0, 20) : [])
if (!name && !email) { skipped++; continue }
if (email && !EMAIL_RE.test(email)) { skipped++; continue }
toAdd.push({ name, email, notes, tags })
}
for (const row of toAdd) {
const submission = {
id: randomUUID(),
submittedAt: new Date().toISOString(),
name: row.name,
email: row.email,
message: '',
messageType: 'general',
subscribe: false,
archived: false,
source: 'manual',
notes: row.notes,
tags: row.tags,
emailStatus: normalizeContactEmailStatus(null, false),
}
state.contactSubmissions.unshift(submission)
created++
}
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
if (created > 0) queueContactSubmissionsWrite()
if (created > 0) appendAuditEntry('contacts-imported', `${created} contacts imported, ${skipped} skipped`)
res.json({ ok: true, created, skipped })
})
app.get('/api/admin-contact-submissions/:id/attachments/:attachmentId', requireAdminAuth, (req, res) => {
const { id, attachmentId } = req.params
const submission = state.contactSubmissions.find(s => s.id === id)
if (!submission) { res.status(404).send('Not found.'); return }
const attachment = (submission.attachments ?? []).find(a => a.id === attachmentId)
if (!attachment) { res.status(404).send('Attachment not found.'); return }
const safe = attachment.filename.replace(/[^\w.\-]/g, '_')
res.setHeader('Content-Disposition', `attachment; filename="${safe}"`)
res.setHeader('Content-Type', attachment.contentType || 'application/octet-stream')
res.send(Buffer.from(attachment.data, 'base64'))
})
app.get('/api/admin-reply-config', requireAdminAuth, (_req, res) => {
res.json({
fromEmail: getResendReplyToAddress(),
fromIdentity: getResendFromAddress() || ADMIN_REPLY_FROM,
resendApiConfigured: Boolean(process.env.RESEND_API_KEY),
canSendReplies: Boolean(process.env.RESEND_API_KEY),
note: process.env.RESEND_API_KEY
? 'App is configured to attempt sends through Resend. Delivery still depends on Resend sender/domain verification.'
: 'RESEND_API_KEY is missing, so admin replies cannot be sent yet.',
})
})
app.get('/api/admin-contact-reply-templates', requireAdminAuth, (_req, res) => {
res.json({ templates: state.replyTemplates })
})
app.put('/api/admin-contact-reply-templates', requireAdminAuth, (req, res) => {
const nextTemplates = sanitizeReplyTemplates(req.body?.templates)
state.replyTemplates = nextTemplates
queueReplyTemplatesWrite()
res.json({ ok: true, templates: state.replyTemplates })
})
app.get('/api/admin-contact-reply-history', requireAdminAuth, (_req, res) => {
res.json({ items: state.replyHistory.slice(0, 100) })
})
app.post('/api/admin-contact-submissions/:id/reply', requireAdminAuth, async (req, res) => {
try {
if (!process.env.RESEND_API_KEY) {
res.status(503).json({ message: 'RESEND_API_KEY is not configured on the server.' }); return
}
const { id } = req.params
const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : ''
const message = typeof req.body?.message === 'string' ? req.body.message.trim() : ''
const requestedFrom = typeof req.body?.fromAddress === 'string' ? req.body.fromAddress.trim() : ''
const scheduledAt = typeof req.body?.scheduledAt === 'string' && !isNaN(Date.parse(req.body.scheduledAt)) && new Date(req.body.scheduledAt) > new Date() ? req.body.scheduledAt : null
if (!id || typeof id !== 'string') {
res.status(400).json({ message: 'Invalid submission id.' }); return
}
if (!subject || subject.length > 180) {
res.status(400).json({ message: 'Subject is required and must be 180 characters or fewer.' }); return
}
if (!message || message.length > 6000) {
res.status(400).json({ message: 'Message is required and must be 6000 characters or fewer.' }); return
}
const submission = state.contactSubmissions.find(entry => entry.id === id)
if (!submission) {
res.status(404).json({ message: 'Submission not found.' }); return
}
if (!submission.email || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(submission.email)) {
res.status(400).json({ message: 'Submission does not have a valid email address.' }); return
}
const recipientName = splitName(submission.name).firstName || submission.name || 'friend'
const signature = state.emailSettings?.signature ?? 'Grace and peace,\nVerse by Verse with Nate'
const html = buildAdminReplyTemplate({ recipientName, message, signature })
const replyToAddress = getResendReplyToAddress()
const defaultFrom = getResendFromAddress() || ADMIN_REPLY_FROM
const fromAddress = ADMIN_REPLY_FROM_OPTIONS.includes(requestedFrom) ? requestedFrom : defaultFrom
const text = `Hi ${recipientName},\n\n${message}\n\n${signature}\n${replyToAddress}`
const resend = new Resend(process.env.RESEND_API_KEY)
const sendResult = await sendResendEmailWithRetry({
resend,
context: 'admin-contact-reply',
payload: {
from: fromAddress,
to: [submission.email],
subject,
replyTo: replyToAddress,
...(scheduledAt ? { scheduledAt } : {}),
tags: [
{ name: 'flow', value: 'admin-reply' },
{ name: 'message_type', value: submission.messageType ?? 'general' },
{ name: 'submission_id', value: submission.id },
],
headers: {
'X-Contact-Submission-Id': submission.id,
...(typeof submission.messageId === 'string' && MESSAGE_ID_RE.test(submission.messageId) ? {
'In-Reply-To': submission.messageId,
'References': submission.messageId,
} : {}),
},
text,
html,
},
})
registerResendMessageForSubmission(submission.id, 'adminReply', sendResult)
upsertContactEmailStatus(submission.id, 'adminReply', { status: 'sent', lastEventType: 'email.sent', error: null })
state.replyHistory.unshift({
id: randomUUID(),
submissionId: submission.id,
toEmail: submission.email,
toName: submission.name,
fromEmail: replyToAddress,
subject,
preview: message.slice(0, 500),
sentAt: new Date().toISOString(),
scheduledAt: scheduledAt ?? null,
})
state.replyHistory = state.replyHistory.slice(0, 500)
queueReplyHistoryWrite()
appendAuditEntry('reply-sent', `To: ${submission.email} | Subject: ${subject}${scheduledAt ? ` | Scheduled: ${scheduledAt}` : ''}`)
fireWebhooks('reply.sent', { toEmail: submission.email, toName: submission.name, subject, sentAt: new Date().toISOString() })
res.json({ ok: true, scheduled: Boolean(scheduledAt), scheduledAt: scheduledAt ?? null })
} catch (err) {
if (typeof req.params?.id === 'string' && req.params.id.trim()) {
upsertContactEmailStatus(req.params.id.trim(), 'adminReply', {
status: 'failed',
lastEventType: 'email.failed',
error: String(err?.message ?? err ?? 'unknown error').slice(0, 600),
})
}
console.error('[admin-reply] send error:', err)
res.status(500).json({ message: 'Failed to send reply email.' })
}
})
app.post('/api/admin-email/compose', requireAdminAuth, async (req, res) => {
try {
if (!process.env.RESEND_API_KEY) {
res.status(503).json({ message: 'RESEND_API_KEY is not configured on the server.' }); return
}
const to = typeof req.body?.to === 'string' ? req.body.to.trim() : ''
const toName = typeof req.body?.toName === 'string' ? req.body.toName.trim() : ''
const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : ''
const message = typeof req.body?.message === 'string' ? req.body.message.trim() : ''
const requestedFrom = typeof req.body?.fromAddress === 'string' ? req.body.fromAddress.trim() : ''
const scheduledAt = typeof req.body?.scheduledAt === 'string' && !isNaN(Date.parse(req.body.scheduledAt)) && new Date(req.body.scheduledAt) > new Date() ? req.body.scheduledAt : null
if (!to || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(to)) {
res.status(400).json({ message: 'A valid recipient email address is required.' }); return
}
if (!subject || subject.length > 180) {
res.status(400).json({ message: 'Subject is required and must be 180 characters or fewer.' }); return
}
if (!message || message.length > 6000) {
res.status(400).json({ message: 'Message is required and must be 6000 characters or fewer.' }); return
}
const recipientName = toName ? splitName(toName).firstName || toName : 'friend'
const signature = state.emailSettings?.signature ?? 'Grace and peace,\nVerse by Verse with Nate'
const html = buildAdminReplyTemplate({ recipientName, message, signature })
const defaultFrom = getResendFromAddress() || ADMIN_REPLY_FROM
const fromAddress = ADMIN_REPLY_FROM_OPTIONS.includes(requestedFrom) ? requestedFrom : defaultFrom
const replyToAddress = getResendReplyToAddress()
const text = `Hi ${recipientName},\n\n${message}\n\n${signature}`
const resend = new Resend(process.env.RESEND_API_KEY)
await sendResendEmailWithRetry({
resend,
context: 'admin-compose',
payload: {
from: fromAddress,
to: [to],
subject,
replyTo: replyToAddress,
...(scheduledAt ? { scheduledAt } : {}),
tags: [{ name: 'flow', value: 'admin-reply' }],
text,
html,
},
})
state.replyHistory.unshift({
id: randomUUID(),
submissionId: '',
toEmail: to,
toName: toName || to,
fromEmail: replyToAddress,
subject,
preview: message.slice(0, 500),
sentAt: new Date().toISOString(),
scheduledAt: scheduledAt ?? null,
})
state.replyHistory = state.replyHistory.slice(0, 500)
queueReplyHistoryWrite()
appendAuditEntry('email-sent', `To: ${to} | Subject: ${subject}${scheduledAt ? ` | Scheduled: ${scheduledAt}` : ''}`)
res.json({ ok: true, scheduled: Boolean(scheduledAt), scheduledAt: scheduledAt ?? null })
} catch (err) {
console.error('[admin-compose] send error:', err)
res.status(500).json({ message: 'Failed to send email.' })
}
})
app.get('/api/admin-email-settings', requireAdminAuth, (_req, res) => {
res.json(state.emailSettings ?? { signature: 'Grace and peace,\nVerse by Verse with Nate' })
})
app.put('/api/admin-email-settings', requireAdminAuth, (req, res) => {
const signature = typeof req.body?.signature === 'string'
? req.body.signature.slice(0, 1000)
: (state.emailSettings?.signature ?? 'Grace and peace,\nVerse by Verse with Nate')
state.emailSettings = { ...state.emailSettings, signature }
queueEmailSettingsWrite()
res.json({ ok: true, settings: state.emailSettings })
})
app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => {
const seen = new Set()
const subscribers = state.contactSubmissions
.filter(entry => entry.subscribe === true && entry.email && !seen.has(entry.email) && seen.add(entry.email))
.map(entry => ({
name: entry.name,
email: entry.email,
subscribedAt: entry.submittedAt,
source: entry.message?.startsWith('Requested') ? 'download' : 'contact-form',
}))
.sort((a, b) => new Date(b.subscribedAt).getTime() - new Date(a.subscribedAt).getTime())
res.json({ subscribers, total: subscribers.length })
})
app.post('/api/admin-subscribers/export', requireAdminAuth, (_req, res) => {
const seen = new Set()
const rows = [['Name', 'Email', 'Subscribed At', 'Source']]
state.contactSubmissions
.filter(entry => entry.subscribe === true && entry.email && !seen.has(entry.email) && seen.add(entry.email))
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
.forEach(entry => {
const source = entry.message?.startsWith('Requested') ? 'download' : 'contact-form'
rows.push([entry.name, entry.email, entry.submittedAt, source])
})
const csv = rows.map(row => row.map(cell => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(',')).join('\n')
res.setHeader('Content-Type', 'text/csv')
res.setHeader('Content-Disposition', `attachment; filename="subscribers-${new Date().toISOString().slice(0, 10)}.csv"`)
res.send(csv)
})
app.get('/api/admin-audit-log', requireAdminAuth, (_req, res) => {
res.json({ entries: state.auditLog.slice(0, 200) })
})
app.post('/api/admin-broadcasts/send', requireAdminAuth, async (req, res) => {
if (!process.env.RESEND_API_KEY) { res.status(503).json({ message: 'RESEND_API_KEY is not configured.' }); return }
const audienceId = process.env.RESEND_AUDIENCE_ID
if (!audienceId) { res.status(503).json({ message: 'RESEND_AUDIENCE_ID is not configured.' }); return }
const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : ''
const text = typeof req.body?.text === 'string' ? req.body.text.trim() : ''
const fromLabel = typeof req.body?.from === 'string' ? req.body.from.trim() : getResendFromAddress()
const previewText = typeof req.body?.previewText === 'string' ? req.body.previewText.trim() : ''
if (!subject) { res.status(400).json({ message: 'Subject is required.' }); return }
if (!text) { res.status(400).json({ message: 'Message body is required.' }); return }
const paragraphs = text.split(/\n{2,}/).map(p => p.replace(/\n/g, '<br>')).map(p => `<p>${p}</p>`).join('')
const html = `<!DOCTYPE html><html><body style="font-family:Georgia,serif;max-width:600px;margin:0 auto;padding:24px;color:#1a1a1a;">${paragraphs}</body></html>`
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const { data: created, error: createErr } = await resend.broadcasts.create({
audienceId,
from: fromLabel,
subject,
...(previewText ? { previewText } : {}),
html,
text,
})
if (createErr || !created?.id) {
res.status(502).json({ message: createErr?.message || 'Broadcast creation failed.' }); return
}
const { error: sendErr } = await resend.broadcasts.send(created.id)
if (sendErr) { res.status(502).json({ message: sendErr.message || 'Broadcast send failed.' }); return }
appendAuditEntry('broadcast-sent', `Subject: ${subject} | AudienceId: ${audienceId}`)
res.json({ ok: true, broadcastId: created.id })
} catch (err) {
res.status(502).json({ message: String(err?.message ?? 'Broadcast failed.') })
}
})
app.post('/api/admin-contacts/trigger-drip', requireAdminAuth, async (req, res) => {
if (!process.env.RESEND_API_KEY) { res.status(503).json({ message: 'RESEND_API_KEY is not configured.' }); return }
const automationId = process.env.RESEND_AUTOMATION_WELCOME || ''
if (!automationId) { res.status(503).json({ message: 'No RESEND_AUTOMATION_* configured.' }); return }
const email = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''
const tag = typeof req.body?.tag === 'string' ? req.body.tag.trim().slice(0, 50) : ''
if (!email) { res.status(400).json({ message: 'email is required.' }); return }
try {
const resend = new Resend(process.env.RESEND_API_KEY)
// Add or update the contact in the Resend audience so the automation can fire
const audienceId = process.env.RESEND_AUDIENCE_ID || ''
if (audienceId) {
await resend.contacts.create({ audienceId, email, unsubscribed: false }).catch(() => {})
}
appendAuditEntry('drip-triggered', `email: ${email}${tag ? ` | tag: ${tag}` : ''}`)
res.json({ ok: true, note: 'Contact synced to Resend audience; automation will fire based on your Resend settings.' })
} catch (err) {
res.status(502).json({ message: String(err?.message ?? 'Drip trigger failed.') })
}
})
app.post('/api/admin-contact-submissions/:id/draft-reply', requireAdminAuth, async (req, res) => {
const apiKey = process.env.ANTHROPIC_API_KEY
if (!apiKey) { res.status(503).json({ message: 'ANTHROPIC_API_KEY is not configured on the server.' }); return }
const { id } = req.params
if (!id || typeof id !== 'string') { res.status(400).json({ message: 'Invalid submission id.' }); return }
const submission = state.contactSubmissions.find(s => s.id === id.trim())
if (!submission) { res.status(404).json({ message: 'Submission not found.' }); return }
const senderName = submission.name?.trim() || 'this listener'
const messageText = submission.message?.trim() || '(no message body)'
try {
const apiRes = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-haiku-4-5-20251001',
max_tokens: 400,
messages: [{
role: 'user',
content: `Draft a warm, personal reply to this message from a podcast listener. Write only the reply body — no greeting line (handled separately), no sign-off (handled by a signature). Keep it under 150 words. Be genuine and specific to their message.\n\nFrom: ${senderName}\nMessage:\n${messageText.slice(0, 1500)}`,
}],
}),
})
if (!apiRes.ok) {
const err = await apiRes.json().catch(() => ({}))
res.status(502).json({ message: err?.error?.message || 'AI draft request failed.' }); return
}
const data = await apiRes.json()
const draft = String(data?.content?.[0]?.text ?? '').trim()
res.json({ ok: true, draft })
} catch {
res.status(502).json({ message: 'AI draft request failed.' })
}
})
// ── Webhook CRUD ───────────────────────────────────────────────────────────
app.get('/api/admin-webhooks', requireAdminAuth, (_req, res) => {
res.json({ webhooks: state.webhooks })
})
app.post('/api/admin-webhooks', requireAdminAuth, (req, res) => {
const url = typeof req.body?.url === 'string' ? req.body.url.trim() : ''
const events = Array.isArray(req.body?.events) ? req.body.events.filter(e => typeof e === 'string' && e.trim()) : ['*']
const label = typeof req.body?.label === 'string' ? req.body.label.trim().slice(0, 100) : ''
if (!url || !/^https?:\/\/./.test(url)) {
res.status(400).json({ message: 'A valid http/https URL is required.' }); return
}
const webhook = { id: randomUUID(), url: url.slice(0, 500), label, events, createdAt: new Date().toISOString() }
state.webhooks.push(webhook)
state.webhooks = state.webhooks.slice(0, 50)
queueWebhooksWrite()
appendAuditEntry('webhook-added', url)
res.json({ ok: true, webhook })
})
app.delete('/api/admin-webhooks/:id', requireAdminAuth, (req, res) => {
const { id } = req.params
const before = state.webhooks.length
state.webhooks = state.webhooks.filter(w => w.id !== id)
if (state.webhooks.length === before) { res.status(404).json({ message: 'Webhook not found.' }); return }
queueWebhooksWrite()
appendAuditEntry('webhook-removed', `id: ${id}`)
res.json({ ok: true })
})
app.post('/api/admin-webhooks/:id/test', requireAdminAuth, async (req, res) => {
const wh = state.webhooks.find(w => w.id === req.params.id)
if (!wh) { res.status(404).json({ message: 'Webhook not found.' }); return }
try {
const r = await fetch(wh.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'User-Agent': 'Siteforge-Webhook/1.0' },
body: JSON.stringify({ event: 'test', data: { message: 'Test event from Siteforge.' }, firedAt: new Date().toISOString() }),
signal: AbortSignal.timeout(8000),
})
appendAuditEntry('webhook-test', `${wh.url}${r.status}`)
res.json({ ok: true, status: r.status })
} catch (err) {
res.status(502).json({ message: String(err?.message ?? 'Test failed.') })
}
})
}
+216
View File
@@ -0,0 +1,216 @@
import { stat } from 'node:fs/promises'
import { requireAdminAuth } from '../auth.js'
import { TITUS_STUDY_FILE, TITUS_STUDY_DOWNLOAD_NAME } from '../config.js'
import { state } from '../state.js'
import {
loadSiteContentFile,
queueContactSubmissionsWrite,
normalizeContactEmailStatus,
normalizeMessageType,
incrementDownloadCount,
} from '../data.js'
import {
createTitusDownloadToken,
consumeTitusDownloadToken,
sanitizeUrl,
} from '../study-helpers.js'
import { syncContactToResend } from '../email.js'
import { DATA_FILE, MAX_CONTACT_SUBMISSIONS } from '../config.js'
import { randomUUID } from 'node:crypto'
const downloadHits = new Map()
function studyDownloadRateLimit(req, res, next) {
const ip = req.ip ?? 'unknown'
const now = Date.now()
const windowMs = 10 * 60 * 1000
const entry = downloadHits.get(ip) ?? { count: 0, start: now }
if (now - entry.start > windowMs) { entry.count = 0; entry.start = now }
entry.count += 1
downloadHits.set(ip, entry)
// Prune stale entries to prevent unbounded growth
if (downloadHits.size > 5000) {
const cutoff = now - windowMs
for (const [k, v] of downloadHits) { if (v.start < cutoff) downloadHits.delete(k) }
}
if (entry.count > 10) {
res.status(429).json({ message: 'Too many download requests. Please wait a few minutes.' })
return
}
next()
}
function addContactSubmission({ name, email, message, messageType, subscribe }) {
const wantsWelcome = subscribe === true
const submission = {
id: randomUUID(),
submittedAt: new Date().toISOString(),
name,
email,
message,
messageType: normalizeMessageType(messageType),
subscribe: wantsWelcome,
archived: false,
emailStatus: normalizeContactEmailStatus(null, wantsWelcome),
}
state.contactSubmissions.unshift(submission)
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
queueContactSubmissionsWrite()
return submission
}
export function register(app) {
app.post('/api/study-downloads/titus', studyDownloadRateLimit, async (req, res) => {
try {
const { firstName, lastName, email, subscribe, _honey } = req.body ?? {}
if (_honey) { res.json({ ok: true }); 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 configuredDownloadUrl = sanitizeUrl(published?.siteContent?.studyGuideDownloadUrl)
if (!configuredDownloadUrl) {
try {
await stat(TITUS_STUDY_FILE)
} catch {
res.status(503).json({ message: 'The primary study guide download URL is not configured yet.' }); return
}
}
const trimmedFirstName = firstName.trim()
const trimmedLastName = lastName.trim()
const trimmedName = `${trimmedFirstName} ${trimmedLastName}`.trim()
const trimmedEmail = email.trim()
const wantsSubscribe = subscribe !== false
addContactSubmission({ name: trimmedName, email: trimmedEmail, message: 'Requested Titus study download.', messageType: 'general', subscribe: wantsSubscribe })
if (wantsSubscribe) {
await syncContactToResend(trimmedName, trimmedEmail)
}
incrementDownloadCount('titus-study')
if (configuredDownloadUrl) {
res.json({ ok: true, downloadUrl: configuredDownloadUrl }); return
}
const token = createTitusDownloadToken(trimmedEmail)
res.json({ ok: true, downloadUrl: `/api/study-downloads/titus/file?token=${encodeURIComponent(token)}` })
} catch (err) {
console.error('[study-download] request error:', err)
res.status(500).json({ message: 'Failed to process your request. Please try again.' })
}
})
app.get('/api/study-downloads/titus/file', async (req, res) => {
const token = typeof req.query?.token === 'string' ? req.query.token : ''
if (!token || !consumeTitusDownloadToken(token)) {
res.status(403).json({ message: 'Invalid or expired download link. Submit the form again.' }); return
}
try {
await stat(TITUS_STUDY_FILE)
res.download(TITUS_STUDY_FILE, TITUS_STUDY_DOWNLOAD_NAME)
} catch {
res.status(503).json({ message: 'The Titus study file is not configured yet.' })
}
})
app.post('/api/resource-download', studyDownloadRateLimit, async (req, res) => {
try {
const { resourceId, firstName, lastName, email, subscribe, _honey } = req.body ?? {}
if (_honey) { res.json({ ok: true }); return }
if (!resourceId || typeof resourceId !== 'string') {
res.status(400).json({ message: 'Resource ID is required.' }); return
}
const published = await loadSiteContentFile(DATA_FILE)
const siteContent = published?.siteContent
function resolveResourceFromId(id) {
if (!siteContent || typeof siteContent !== 'object') return null
const customResources = Array.isArray(siteContent.customLinks)
? siteContent.customLinks.filter(link => link?.placement === 'resources')
: []
if (id.startsWith('custom:')) {
const customId = id.slice('custom:'.length)
const match = customResources.find(link => link.id === customId)
return match ? { label: match.label, url: match.url } : null
}
if (id.startsWith('archived:')) {
const [, seriesId, ...linkIdParts] = id.split(':')
const linkId = linkIdParts.join(':')
const archivedSeries = Array.isArray(siteContent.archivedSeries) ? siteContent.archivedSeries : []
const series = archivedSeries.find(item => item.id === seriesId)
const link = Array.isArray(series?.resourceLinks) ? series.resourceLinks.find(item => item.id === linkId) : null
return link ? { label: link.label || series?.title, url: link.url } : null
}
const customMatch = customResources.find(link => link.id === id)
if (customMatch) return { label: customMatch.label, url: customMatch.url }
const archivedSeries = Array.isArray(siteContent.archivedSeries) ? siteContent.archivedSeries : []
for (const series of archivedSeries) {
if (!Array.isArray(series?.resourceLinks)) continue
const link = series.resourceLinks.find(item => item.id === id)
if (link) return { label: link.label || series?.title, url: link.url }
}
return null
}
const resource = resolveResourceFromId(resourceId)
if (!resource || typeof resource.url !== 'string' || !resource.url.trim()) {
res.status(400).json({ message: 'Resource not found.' }); 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 trimmedFirstName = firstName.trim()
const trimmedLastName = lastName.trim()
const trimmedName = `${trimmedFirstName} ${trimmedLastName}`.trim()
const trimmedEmail = email.trim()
const wantsSubscribe = subscribe !== false
addContactSubmission({
name: trimmedName,
email: trimmedEmail,
message: `Requested resource download: ${resource.label ?? resource.url}`,
messageType: 'general',
subscribe: wantsSubscribe,
})
if (wantsSubscribe) {
await syncContactToResend(trimmedName, trimmedEmail)
}
incrementDownloadCount(`resource:${resourceId}`)
res.json({ ok: true, downloadUrl: resource.url.trim() })
} catch (err) {
console.error('[resource-download] 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) => {
res.json({ counts: state.downloadCounts })
})
}
+134
View File
@@ -0,0 +1,134 @@
import mammoth from 'mammoth'
import { requireAdminAuth } from '../auth.js'
import { state } from '../state.js'
import { queueEpisodeScriptsWrite } from '../data.js'
import { MAX_EPISODE_SCRIPT_LENGTH } from '../config.js'
/**
* Simple whitespace-normalised search over episode script text.
* Returns { episodeNumber, title, filename, snippet, score } for matches.
*/
function searchScripts(query) {
if (!query || query.trim().length < 2) return []
const q = query.trim().toLowerCase()
const words = q.split(/\s+/).filter(w => w.length >= 2)
const results = []
for (const [episodeNumber, entry] of Object.entries(state.episodeScripts)) {
if (!entry?.text) continue
const text = entry.text.toLowerCase()
// Count how many words appear in the text
const matchCount = words.filter(w => text.includes(w)).length
if (matchCount === 0) continue
// Find a snippet around the first matching word
const firstWord = words.find(w => text.includes(w))
const idx = firstWord ? text.indexOf(firstWord) : 0
const start = Math.max(0, idx - 80)
const end = Math.min(entry.text.length, idx + 180)
let snippet = entry.text.slice(start, end).replace(/\s+/g, ' ').trim()
if (start > 0) snippet = '…' + snippet
if (end < entry.text.length) snippet = snippet + '…'
results.push({
episodeNumber,
title: entry.title ?? `Episode ${episodeNumber}`,
filename: entry.filename ?? '',
snippet,
score: matchCount / words.length, // 01: fraction of query words found
})
}
return results.sort((a, b) => b.score - a.score).slice(0, 8)
}
export function register(app) {
// Public script search — called by the frontend global search
app.get('/api/episode-scripts/search', (req, res) => {
const q = typeof req.query.q === 'string' ? req.query.q.trim() : ''
if (!q || q.length < 2) { res.json({ results: [] }); return }
res.json({ results: searchScripts(q) })
})
// Admin: list all uploaded scripts
app.get('/api/admin-episode-scripts', requireAdminAuth, (_req, res) => {
const list = Object.entries(state.episodeScripts).map(([episodeNumber, entry]) => ({
episodeNumber,
title: entry.title ?? `Episode ${episodeNumber}`,
filename: entry.filename ?? '',
uploadedAt: entry.uploadedAt ?? null,
wordCount: entry.text ? entry.text.split(/\s+/).filter(Boolean).length : 0,
}))
list.sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber))
res.json({ scripts: list })
})
// Admin: upload a docx script for an episode
app.post('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, async (req, res) => {
const episodeNumber = req.params.episodeNumber?.trim()
if (!episodeNumber || !/^\d+$/.test(episodeNumber)) {
res.status(400).json({ message: 'Episode number must be a positive integer.' }); return
}
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 title = typeof req.body?.title === 'string' ? req.body.title.trim() : `Episode ${episodeNumber}`
if (!dataUrl.startsWith('data:')) {
res.status(400).json({ message: 'dataUrl must be a valid data URL.' }); return
}
const base64 = dataUrl.split(',')[1] ?? ''
if (!base64) { res.status(400).json({ message: 'Empty file.' }); return }
const buffer = Buffer.from(base64, 'base64')
if (buffer.length === 0 || buffer.length > 20 * 1024 * 1024) {
res.status(400).json({ message: 'File must be between 1 byte and 20MB.' }); return
}
let text
try {
const result = await mammoth.extractRawText({ buffer })
text = result.value.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim()
} catch (err) {
console.error('[episode-scripts] mammoth extraction failed:', err.message)
res.status(422).json({ message: 'Could not extract text from file. Make sure it is a valid .docx file.' }); return
}
if (!text || text.length < 10) {
res.status(422).json({ message: 'No readable text found in document.' }); return
}
// Trim to max length
if (text.length > MAX_EPISODE_SCRIPT_LENGTH) {
text = text.slice(0, MAX_EPISODE_SCRIPT_LENGTH)
}
state.episodeScripts[episodeNumber] = {
title,
filename,
text,
uploadedAt: new Date().toISOString(),
wordCount: text.split(/\s+/).filter(Boolean).length,
}
queueEpisodeScriptsWrite()
res.json({
ok: true,
episodeNumber,
wordCount: state.episodeScripts[episodeNumber].wordCount,
})
})
// Admin: delete a script
app.delete('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, (req, res) => {
const episodeNumber = req.params.episodeNumber?.trim()
if (!state.episodeScripts[episodeNumber]) {
res.status(404).json({ message: 'Script not found.' }); return
}
delete state.episodeScripts[episodeNumber]
queueEpisodeScriptsWrite()
res.json({ ok: true })
})
}
+196
View File
@@ -0,0 +1,196 @@
import { sanitizeUrl } from '../study-helpers.js'
const RSS_FEED_URL = 'https://anchor.fm/s/11068d290/podcast/rss'
let episodesCache = null
let episodesCacheAt = 0
let rawXmlCache = null
const EPISODES_CACHE_TTL = 30 * 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() {
const now = Date.now()
if (episodesCache && (now - episodesCacheAt) < EPISODES_CACHE_TTL) {
return episodesCache
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 8000)
const response = await fetch(RSS_FEED_URL, { signal: controller.signal })
clearTimeout(timeout)
if (!response.ok) throw new Error(`RSS fetch failed: ${response.status}`)
const xml = await response.text()
rawXmlCache = xml
const episodes = parseRssItems(xml)
episodesCache = episodes
episodesCacheAt = now
return episodes
}
export async function fetchRawFeedXml() {
await fetchAllEpisodes()
return rawXmlCache
}
function toSpotifyEpisodeEmbedUrl(urlValue) {
if (!urlValue) return ''
try {
const parsed = new URL(urlValue)
if (parsed.protocol !== 'https:') return ''
const host = parsed.hostname.toLowerCase()
const parts = parsed.pathname.split('/').filter(Boolean)
if (host === 'open.spotify.com') {
if (parts[0] === 'embed' && parts[1] === 'episode' && parts[2]) {
return `https://open.spotify.com/embed/episode/${parts[2]}?utm_source=generator`
}
if (parts[0] === 'episode' && parts[1]) {
return `https://open.spotify.com/embed/episode/${parts[1]}?utm_source=generator`
}
}
} catch { return '' }
return ''
}
function decodeEscapedJsonUrl(value) {
return String(value || '').replace(/\\u002F/g, '/').replace(/\\\//g, '/')
}
function extractSpotifyEpisodeIdFromCreatorHtml(html, sourceUrl) {
const input = String(html || '')
if (!input) return ''
const sourceEpisodeSlug = /-([A-Za-z0-9]+)(?:\/|$)/.exec(sourceUrl)?.[1] ?? ''
const blockRegex = /"episodeId":"([^"]+)"[\s\S]*?"spotifyUrl":"([^"]+)"/g
let match
let firstEpisodeId = ''
while ((match = blockRegex.exec(input)) !== null) {
const episodeSlug = match[1]
const spotifyUrl = decodeEscapedJsonUrl(match[2])
const episodeId = /\/episode\/([A-Za-z0-9]+)/.exec(spotifyUrl)?.[1]
if (!firstEpisodeId && episodeId) firstEpisodeId = episodeId
if (sourceEpisodeSlug && episodeSlug === sourceEpisodeSlug && episodeId) return episodeId
}
if (firstEpisodeId) return firstEpisodeId
const urlMatch = /"spotifyUrl":"(https:\\u002F\\u002Fopen\.spotify\.com\\u002Fepisode\\u002F([A-Za-z0-9]+))/.exec(input)
return urlMatch ? (urlMatch[2] || '') : ''
}
function isAllowedSpotifyResolverHost(hostname) {
const host = String(hostname || '').toLowerCase()
return host === 'open.spotify.com' || host === 'creators.spotify.com' || host === 'anchor.fm' || host === 'podcasters.spotify.com'
}
export function register(app) {
app.get('/api/spotify/embed-url', async (req, res) => {
const incoming = typeof req.query.url === 'string' ? req.query.url.trim() : ''
const safeInput = sanitizeUrl(incoming)
if (!safeInput || safeInput.startsWith('/')) {
res.status(400).json({ message: 'A valid episode URL is required.' }); return
}
let parsed
try {
parsed = new URL(safeInput)
} catch {
res.status(400).json({ message: 'Malformed URL.' }); return
}
if (parsed.protocol !== 'https:' || !isAllowedSpotifyResolverHost(parsed.hostname)) {
res.status(400).json({ message: 'Unsupported episode URL host.' }); return
}
const directEmbed = toSpotifyEpisodeEmbedUrl(safeInput)
if (directEmbed) {
res.json({ embedUrl: directEmbed, resolvedFrom: 'direct' }); return
}
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 8000)
const response = await fetch(safeInput, {
signal: controller.signal,
headers: { 'User-Agent': 'Siteforge/1.0 (+https://versebyversewithnate.us)', Accept: 'text/html' },
})
clearTimeout(timeout)
if (!response.ok) {
res.status(404).json({ message: 'Could not fetch episode page.' }); return
}
const html = await response.text()
const spotifyEpisodeId = extractSpotifyEpisodeIdFromCreatorHtml(html, safeInput)
if (!spotifyEpisodeId) {
res.status(404).json({ message: 'Could not resolve Spotify episode ID from page.' }); return
}
const embedUrl = `https://open.spotify.com/embed/episode/${spotifyEpisodeId}?utm_source=generator`
res.json({ embedUrl, resolvedFrom: 'page-fetch' })
} catch (err) {
console.error('[spotify/embed-url] resolve error:', err.message)
res.status(500).json({ message: 'Could not resolve Spotify embed URL right now.' })
}
})
app.get('/api/episodes', async (_req, res) => {
try {
const episodes = await fetchAllEpisodes()
res.json({ episodes: episodes.slice(0, 6) })
} catch (err) {
console.error('[episodes] RSS fetch error:', err.message)
res.json({ episodes: (episodesCache ?? []).slice(0, 6) })
}
})
app.get('/api/episodes/all', async (_req, res) => {
try {
const episodes = await fetchAllEpisodes()
res.json({ episodes })
} catch (err) {
console.error('[episodes/all] RSS fetch error:', err.message)
res.json({ episodes: episodesCache ?? [] })
}
})
app.get('/api/episode-audio', async (_req, res) => {
try {
const episodes = await fetchAllEpisodes()
res.json({ episodes: episodes.map(e => ({ title: e.title, audioUrl: e.audioUrl, duration: e.duration, episode: e.episode })) })
} catch (err) {
console.error('[episode-audio] RSS fetch error:', err.message)
res.json({ episodes: (episodesCache ?? []).map(e => ({ title: e.title, audioUrl: e.audioUrl, duration: e.duration, episode: e.episode })) })
}
})
}
+35
View File
@@ -0,0 +1,35 @@
import { fetchRawFeedXml } from './episodes.js'
import { getCanonicalBaseUrl } from '../email.js'
export function register(app) {
app.get('/feed.xml', async (_req, res) => {
try {
const xml = await fetchRawFeedXml()
if (!xml) {
res.status(503).send('Feed temporarily unavailable')
return
}
const baseUrl = getCanonicalBaseUrl().replace(/\/$/, '')
const feedUrl = `${baseUrl}/feed.xml`
// Rewrite channel <link> and <atom:link href> to our canonical domain
const rewritten = xml
.replace(
/<atom:link[^>]+rel="self"[^>]*\/>/,
`<atom:link href="${feedUrl}" rel="self" type="application/rss+xml"/>`
)
.replace(
/(<channel>[\s\S]*?<link>)[^<]*(\/link>)/,
`$1${baseUrl}$2`
)
res.set('Content-Type', 'application/rss+xml; charset=utf-8')
res.set('Cache-Control', 'public, max-age=1800')
res.send(rewritten)
} catch (err) {
console.error('[feed.xml]', err?.message ?? err)
res.status(502).send('Failed to fetch feed')
}
})
}
+133
View File
@@ -0,0 +1,133 @@
import { randomUUID, timingSafeEqual } from 'node:crypto'
import { state } from '../state.js'
import { queueContactSubmissionsWrite } from '../data.js'
import { MAX_CONTACT_SUBMISSIONS } from '../config.js'
const MESSAGE_ID_RE = /^<[\x21-\x7E]+>$/
const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024 // 5 MB total per email
function decodeHtmlBody(raw) {
if (typeof raw !== 'string' || !raw.trim()) return null
const s = raw.trim()
if (s.startsWith('<')) return s
try {
const decoded = Buffer.from(s.replace(/\s+/g, ''), 'base64').toString('utf8')
return decoded.trimStart().startsWith('<') ? decoded : s
} catch {
return s
}
}
function normalizeSubject(subject) {
return (subject ?? '').toLowerCase().replace(/^(re|fwd?):\s*/i, '').trim()
}
function resolveThreadId(fromEmail, normalizedSubject, inReplyTo) {
// 1. Exact In-Reply-To match
if (inReplyTo) {
const parent = state.contactSubmissions.find(s => s.messageId === inReplyTo)
if (parent?.threadId) return parent.threadId
}
// 2. Same sender + matching subject within 30 days
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000
const match = state.contactSubmissions.find(s =>
s.email === fromEmail &&
normalizedSubject &&
normalizeSubject(s.message.match(/^Subject:\s*(.+)/m)?.[1] ?? '') === normalizedSubject &&
new Date(s.submittedAt).getTime() > cutoff,
)
if (match?.threadId) return match.threadId
// 3. New thread
return randomUUID()
}
function parseAttachments(raw) {
if (!Array.isArray(raw)) return []
let totalBytes = 0
const out = []
for (const a of raw.slice(0, 10)) {
if (!a || typeof a.filename !== 'string') continue
const dataStr = typeof a.content === 'string' ? a.content : ''
const size = typeof a.size === 'number' ? a.size : Math.floor(dataStr.length * 0.75)
if (totalBytes + size > MAX_ATTACHMENT_BYTES) continue
totalBytes += size
out.push({
id: randomUUID(),
filename: a.filename.slice(0, 255),
contentType: typeof a.contentType === 'string' ? a.contentType : 'application/octet-stream',
size,
data: dataStr,
})
}
return out
}
export function register(app) {
app.post('/api/inbound-email', (req, res) => {
const secret = process.env.INBOUND_EMAIL_SECRET
if (!secret) {
res.status(503).json({ message: 'Inbound email not configured.' }); return
}
const provided = req.get('x-webhook-secret') ?? ''
const a = Buffer.from(provided, 'utf8')
const b = Buffer.from(secret, 'utf8')
if (!provided || a.length !== b.length || !timingSafeEqual(a, b)) {
res.status(401).json({ message: 'Unauthorized.' }); return
}
const { from, to, subject, body, htmlBody, date, messageId, inReplyTo, attachments: rawAttachments, source } = req.body ?? {}
if (!from || typeof from !== 'string') {
res.status(400).json({ message: 'Missing from address.' }); return
}
const fromMatch = /^(.*?)\s*<([^>]+)>$/.exec(from.trim())
const fromEmail = fromMatch ? fromMatch[2].trim() : from.trim()
const fromName = fromMatch ? fromMatch[1].trim() : from.trim()
const normalizedMessageId = typeof messageId === 'string' && MESSAGE_ID_RE.test(messageId.trim()) ? messageId.trim() : ''
const normalizedInReplyTo = typeof inReplyTo === 'string' && MESSAGE_ID_RE.test(inReplyTo.trim()) ? inReplyTo.trim() : ''
if (normalizedMessageId) {
const exists = state.contactSubmissions.some(s => s.messageId === normalizedMessageId)
if (exists) {
res.json({ ok: true, duplicate: true }); return
}
}
const subjectStr = typeof subject === 'string' ? subject.trim() : ''
const threadId = resolveThreadId(fromEmail, normalizeSubject(subjectStr), normalizedInReplyTo)
const submission = {
id: randomUUID(),
threadId,
submittedAt: (typeof date === 'string' || typeof date === 'number') && Number.isFinite(Date.parse(date)) ? new Date(date).toISOString() : new Date().toISOString(),
name: fromName || fromEmail,
email: fromEmail,
message: [subjectStr ? `Subject: ${subjectStr}` : '', body ?? ''].filter(Boolean).join('\n\n'),
htmlBody: decodeHtmlBody(htmlBody),
messageType: 'general',
subscribe: false,
archived: false,
starred: false,
snoozedUntil: null,
source: 'inbound-email',
inboundTo: typeof to === 'string' ? to : '',
messageId: normalizedMessageId,
attachments: parseAttachments(rawAttachments),
emailStatus: {
welcome: { status: 'not-applicable', lastEventAt: null, lastEventType: null, resendEmailId: null, error: null },
adminNotification: { status: 'not-applicable', lastEventAt: null, lastEventType: null, resendEmailId: null, error: null },
adminReply: { status: 'pending', lastEventAt: null, lastEventType: null, resendEmailId: null, error: null },
},
}
state.contactSubmissions.unshift(submission)
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
queueContactSubmissionsWrite()
console.log(`[inbound-email] received from ${fromEmail} — subject: ${subjectStr || '(none)'} — thread: ${threadId}`)
res.json({ ok: true })
})
}
+156
View File
@@ -0,0 +1,156 @@
import express from 'express'
import path from 'node:path'
import { readFile } from 'node:fs/promises'
import { escapeHtml, escapeXml, injectSeoIntoHtml, normalizeSitemapPath } from '../helpers.js'
import {
DIST_DIR,
DIST_IMAGES_DIR,
PUBLIC_IMAGES_DIR,
UPLOADS_DIR,
INDEX_FILE,
DEFAULT_SEO,
APP_VERSION,
GIT_COMMIT,
} from '../config.js'
import { state } from '../state.js'
import { loadSiteContentFile } from '../data.js'
import { DATA_FILE } from '../config.js'
import { sanitizeRedirectRules } from '../study-helpers.js'
export function register(app) {
const SALVATION_INDEX_FILE = path.join(DIST_DIR, 'salvation', 'index.html')
app.get('/api/version', (_req, res) => {
res.json({ version: APP_VERSION, commit: GIT_COMMIT })
})
app.get('/robots.txt', async (_req, res) => {
let content = state.cachedSiteContent
if (!content) {
try {
const parsed = await loadSiteContentFile(DATA_FILE)
content = parsed.siteContent
} catch { content = {} }
}
const seo = content?.seo ?? DEFAULT_SEO
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
res.type('text/plain').send(
['User-agent: *', 'Allow: /', 'Disallow: /salvation', `Sitemap: ${root}/sitemap.xml`].join('\n'),
)
})
app.get('/sitemap.xml', async (_req, res) => {
let content = state.cachedSiteContent
if (!content) {
try {
const parsed = await loadSiteContentFile(DATA_FILE)
content = parsed.siteContent
} catch { content = {} }
}
const seo = content?.seo ?? DEFAULT_SEO
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
const paths = Array.isArray(seo.sitemapPaths) && seo.sitemapPaths.length > 0
? seo.sitemapPaths
: DEFAULT_SEO.sitemapPaths
const urls = paths
.map(item => normalizeSitemapPath(item))
.filter(Boolean)
.map(item => `${root}${item}`)
const xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
...urls.map(url => ` <url><loc>${escapeXml(url)}</loc></url>`),
'</urlset>',
].join('\n')
res.type('application/xml').send(xml)
})
// Redirect rules middleware
app.use((req, res, next) => {
const rules = sanitizeRedirectRules(state.cachedSiteContent?.redirects)
const match = rules.find(rule => rule.path === req.path)
if (!match) { next(); return }
res.redirect(match.statusCode === 302 ? 302 : 301, match.target)
})
// Static files
app.use('/images', express.static(DIST_IMAGES_DIR))
app.use('/images', express.static(PUBLIC_IMAGES_DIR))
app.use('/uploads', express.static(UPLOADS_DIR))
// Explicitly serve salvation page on both paths without relying on slash redirects.
app.get(['/salvation', '/salvation/'], (_req, res, next) => {
res.sendFile(SALVATION_INDEX_FILE, err => {
if (err) next()
})
})
// Social share stub for questions
app.get('/questions/share/:id', (req, res) => {
const id = req.params.id
if (!id || !/^[\w-]{1,120}$/.test(id)) {
res.redirect(302, '/questions'); return
}
const sourceQuestions = state.questions
const question = sourceQuestions.find(q => q.id === id && q.isApproved === true && q.answer)
if (!question) {
res.redirect(302, '/questions'); return
}
const BASE = 'https://versebyversewithnate.us'
const canonicalUrl = `${BASE}/questions#qa-${encodeURIComponent(id)}`
const shareUrl = `${BASE}/questions/share/${encodeURIComponent(id)}`
const ogTitle = escapeHtml(question.question.length > 100
? `${question.question.slice(0, 97)}`
: question.question)
const answerSnippet = question.answer.replace(/\n+/g, ' ').trim()
const ogDescription = escapeHtml(answerSnippet.length > 200
? `${answerSnippet.slice(0, 197)}`
: answerSnippet)
const ogImage = `${BASE}/images/banner.png`
res.type('html').send(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>${ogTitle} — Verse by Verse with Nate</title>
<meta name="description" content="${ogDescription}"/>
<meta property="og:type" content="article"/>
<meta property="og:site_name" content="Verse by Verse with Nate"/>
<meta property="og:url" content="${escapeHtml(shareUrl)}"/>
<meta property="og:title" content="${ogTitle}"/>
<meta property="og:description" content="${ogDescription}"/>
<meta property="og:image" content="${escapeHtml(ogImage)}"/>
<meta property="og:image:alt" content="${ogTitle}"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="${ogTitle}"/>
<meta name="twitter:description" content="${ogDescription}"/>
<meta name="twitter:image" content="${escapeHtml(ogImage)}"/>
<link rel="canonical" href="${escapeHtml(shareUrl)}"/>
<meta http-equiv="refresh" content="0;url=${escapeHtml(canonicalUrl)}"/>
<script>location.replace(${JSON.stringify(canonicalUrl)})</script>
</head>
<body></body>
</html>`)
})
// SPA static files and fallback
app.use(express.static(DIST_DIR))
app.use(async (_req, res) => {
try {
const html = await readFile(INDEX_FILE, 'utf8')
res.type('html').send(injectSeoIntoHtml(html, state.cachedSiteContent))
} catch {
res.status(503).send('Frontend build not found. Run "npm run build" first.')
}
})
}
+88
View File
@@ -0,0 +1,88 @@
import { randomUUID } from 'node:crypto'
import { requireAdminAuth } from '../auth.js'
import { state } from '../state.js'
import { queueQrCodesWrite } from '../data.js'
const MAX_SCANS = 5000
export function register(app) {
// Public redirect — logs the scan, then redirects to destination
app.get('/qr/:slug', (req, res) => {
const { slug } = req.params
const code = state.qrCodes.find(c => c.slug === slug && c.active !== false)
if (!code) { res.status(404).send('QR code not found.'); return }
const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress || 'unknown'
const scan = {
id: randomUUID(),
qrId: code.id,
slug,
scannedAt: new Date().toISOString(),
ip,
userAgent: req.headers['user-agent'] || '',
}
state.qrScans.unshift(scan)
if (state.qrScans.length > MAX_SCANS) state.qrScans.length = MAX_SCANS
queueQrCodesWrite()
res.redirect(302, code.destination)
})
// ── Admin API ──────────────────────────────────────────────────────────────
app.get('/api/admin/qr-codes', requireAdminAuth, (_req, res) => {
const codesWithCounts = state.qrCodes.map(code => ({
...code,
scanCount: state.qrScans.filter(s => s.qrId === code.id).length,
}))
res.json({ codes: codesWithCounts, scans: state.qrScans.slice(0, 200) })
})
app.post('/api/admin/qr-codes', requireAdminAuth, (req, res) => {
const label = typeof req.body?.label === 'string' ? req.body.label.trim().slice(0, 100) : ''
const slug = typeof req.body?.slug === 'string' ? req.body.slug.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 80) : ''
const destination = typeof req.body?.destination === 'string' ? req.body.destination.trim() : ''
if (!label) { res.status(400).json({ message: 'Label is required.' }); return }
if (!slug || !/^[a-z0-9][a-z0-9-]*$/.test(slug)) { res.status(400).json({ message: 'Slug must start with a letter or digit and contain only lowercase letters, digits, and hyphens.' }); return }
if (!destination.startsWith('http')) { res.status(400).json({ message: 'Destination must be a URL.' }); return }
if (state.qrCodes.some(c => c.slug === slug)) { res.status(409).json({ message: 'A QR code with this slug already exists.' }); return }
const code = { id: randomUUID(), slug, label, destination, active: true, createdAt: new Date().toISOString() }
state.qrCodes.push(code)
queueQrCodesWrite()
res.json({ ok: true, code })
})
app.patch('/api/admin/qr-codes/:id', requireAdminAuth, (req, res) => {
const code = state.qrCodes.find(c => c.id === req.params.id)
if (!code) { res.status(404).json({ message: 'QR code not found.' }); return }
if (typeof req.body?.label === 'string') code.label = req.body.label.trim().slice(0, 100)
if (typeof req.body?.destination === 'string') {
if (!req.body.destination.trim().startsWith('http')) { res.status(400).json({ message: 'Destination must be a URL.' }); return }
code.destination = req.body.destination.trim()
}
if (typeof req.body?.active === 'boolean') code.active = req.body.active
queueQrCodesWrite()
res.json({ ok: true, code })
})
app.delete('/api/admin/qr-codes/:id', requireAdminAuth, (req, res) => {
const idx = state.qrCodes.findIndex(c => c.id === req.params.id)
if (idx === -1) { res.status(404).json({ message: 'QR code not found.' }); return }
const { id } = state.qrCodes[idx]
state.qrCodes.splice(idx, 1)
state.qrScans = state.qrScans.filter(s => s.qrId !== id)
queueQrCodesWrite()
res.json({ ok: true })
})
app.delete('/api/admin/qr-codes/:id/scans', requireAdminAuth, (req, res) => {
state.qrScans = state.qrScans.filter(s => s.qrId !== req.params.id)
queueQrCodesWrite()
res.json({ ok: true })
})
}
+186
View File
@@ -0,0 +1,186 @@
import { randomUUID } from 'node:crypto'
import { requireAdminAuth } from '../auth.js'
import { MAX_QUESTIONS } from '../config.js'
import { state } from '../state.js'
import { queueQuestionsWrite, queueDraftQuestionsWrite } from '../data.js'
import { buildQuestionAnsweredEmailTemplate, getResendFromAddress, getResendReplyToAddress, sendResendEmailWithRetry, getCanonicalBaseUrl } from '../email.js'
import { buildAbsoluteUrl } from '../helpers.js'
import { Resend } from 'resend'
function ensureDraftQuestions() {
if (state.draftQuestions !== null) return
state.draftQuestions = state.questions.slice(0, MAX_QUESTIONS)
}
export function register(app) {
app.get('/api/questions', (_req, res) => {
const sourceQuestions = state.draftQuestions ?? state.questions
const publicQuestions = sourceQuestions
.filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0)
.sort((a, b) => {
// Pinned questions always float to top
if (a.pinned && !b.pinned) return -1
if (!a.pinned && b.pinned) return 1
return 0
})
res.json({ questions: publicQuestions })
})
app.get('/api/admin-questions', requireAdminAuth, (_req, res) => {
res.json({ questions: state.draftQuestions ?? state.questions })
})
app.post('/api/admin-questions', requireAdminAuth, (req, res) => {
const firstName = typeof req.body?.firstName === 'string' ? req.body.firstName.trim() : ''
const email = typeof req.body?.email === 'string' ? req.body.email.trim() : ''
const questionText = typeof req.body?.question === 'string' ? req.body.question.trim() : ''
const answerText = typeof req.body?.answer === 'string' ? req.body.answer.trim() : ''
const approveNow = req.body?.approve === true
if (!firstName || firstName.length > 100) {
res.status(400).json({ message: 'First name is required and must be 100 characters or fewer.' }); return
}
if (!questionText || questionText.length < 5 || questionText.length > 3000) {
res.status(400).json({ message: 'Question must be between 5 and 3000 characters.' }); return
}
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
res.status(400).json({ message: 'If provided, email must be a valid email address.' }); return
}
if (answerText.length > 5000) {
res.status(400).json({ message: 'Answer must be 5000 characters or fewer.' }); return
}
ensureDraftQuestions()
const now = new Date().toISOString()
const created = {
id: randomUUID(),
submittedAt: now,
firstName,
email,
question: questionText,
answer: answerText,
answeredAt: answerText ? now : null,
isApproved: approveNow,
approvedAt: approveNow ? now : null,
}
state.draftQuestions.unshift(created)
state.draftQuestions = state.draftQuestions.slice(0, MAX_QUESTIONS)
queueDraftQuestionsWrite()
res.status(201).json({ ok: true, question: created })
})
app.post('/api/admin-questions/:id/answer', requireAdminAuth, (req, res) => {
const { id } = req.params
const { answer } = req.body ?? {}
if (!answer || typeof answer !== 'string' || answer.trim().length < 1 || answer.trim().length > 5000) {
res.status(400).json({ message: 'Answer must be between 1 and 5000 characters.' }); return
}
ensureDraftQuestions()
const question = state.draftQuestions.find(q => q.id === id)
if (!question) {
res.status(404).json({ message: 'Question not found.' }); return
}
question.answer = answer.trim()
question.answeredAt = new Date().toISOString()
queueDraftQuestionsWrite()
res.json({ ok: true, question })
})
app.post('/api/admin-questions/:id/approve', requireAdminAuth, async (req, res) => {
const { id } = req.params
const { approved } = req.body ?? {}
ensureDraftQuestions()
const question = state.draftQuestions.find(q => q.id === id)
if (!question) {
res.status(404).json({ message: 'Question not found.' }); return
}
question.isApproved = approved === true
question.approvedAt = approved === true ? new Date().toISOString() : null
queueDraftQuestionsWrite()
if (approved === true && question.notifyOnAnswer && question.email && question.answer) {
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 })
})
app.delete('/api/admin-questions/:id', requireAdminAuth, (req, res) => {
const { id } = req.params
ensureDraftQuestions()
const index = state.draftQuestions.findIndex(q => q.id === id)
if (index === -1) {
res.status(404).json({ message: 'Question not found.' }); return
}
state.draftQuestions.splice(index, 1)
queueDraftQuestionsWrite()
res.json({ ok: true })
})
// Anonymous upvote — no auth required, lightweight increment
app.post('/api/questions/:id/upvote', (req, res) => {
const { id } = req.params
// Look in live questions first, then draft
const liveQ = state.questions.find(q => q.id === id && q.isApproved === true)
const draftQ = state.draftQuestions ? state.draftQuestions.find(q => q.id === id) : null
const question = liveQ ?? draftQ
if (!question) {
res.status(404).json({ message: 'Question not found.' }); return
}
question.upvotes = ((question.upvotes ?? 0) + 1)
if (liveQ) queueQuestionsWrite()
if (draftQ) queueDraftQuestionsWrite()
res.json({ ok: true, upvotes: question.upvotes })
})
// Admin: pin / unpin a question
app.post('/api/admin-questions/:id/pin', requireAdminAuth, (req, res) => {
const { id } = req.params
ensureDraftQuestions()
const question = state.draftQuestions.find(q => q.id === id)
if (!question) { res.status(404).json({ message: 'Question not found.' }); return }
question.pinned = true
queueDraftQuestionsWrite()
res.json({ ok: true, question })
})
app.delete('/api/admin-questions/:id/pin', requireAdminAuth, (req, res) => {
const { id } = req.params
ensureDraftQuestions()
const question = state.draftQuestions.find(q => q.id === id)
if (!question) { res.status(404).json({ message: 'Question not found.' }); return }
question.pinned = false
queueDraftQuestionsWrite()
res.json({ ok: true, question })
})
}
+491
View File
@@ -0,0 +1,491 @@
import { randomUUID, timingSafeEqual } from 'node:crypto'
import rateLimit from 'express-rate-limit'
import { Document, Packer, Paragraph, HeadingLevel, TextRun } from 'docx'
import { mkdir, unlink, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { inferImageExtensionFromDataUrl, normalizeAssetBaseName, escapeHtml, buildAbsoluteUrl } from '../helpers.js'
import { UPLOADS_DIR, EMAIL_CHANGE_TOKEN_TTL_MS } from '../config.js'
import { state } from '../state.js'
import {
queueStudyUsersWrite,
queueStudyCommunityWrite,
queueStudyCommentsWrite,
queueStudyCertificatesWrite,
readUploadsMetadata,
writeUploadsMetadata,
loadUserNotes,
loadUserProgress,
getUserNotesFilePath,
getUserProgressFilePath,
} from '../data.js'
import {
requireStudyAuth,
hashStudyPassword,
hashEmailChangeToken,
normalizeStudyUsername,
isValidStudyUsername,
findStudyUserByUsername,
getStudyAvatarUrl,
isStudyUserEnrolled,
normalizeStudySlug,
getStudyCatalog,
clearStudySessionCookie,
} from '../study-helpers.js'
import {
sendStudyAccountDeletedEmail,
syncContactToResend,
buildBrandedEmailHtml,
getCanonicalBaseUrl,
getResendFromAddress,
} from '../email.js'
import { Resend } from 'resend'
const studyAuthRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many attempts. Please wait 15 minutes and try again.' },
skipSuccessfulRequests: true,
})
export function register(app) {
app.get('/api/study-account/export-notes', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const notes = await loadUserNotes(user.id)
const progress = await loadUserProgress(user.id)
const sectionMeta = {}
const content = state.cachedSiteContent
const studies = content && Array.isArray(content.studies) && content.studies.length > 0
? content.studies
: [{ slug: 'colossians', title: 'Colossians: Rooted in Christ', description: '', sections: content?.colossiansStudySections ?? [] }]
for (const study of studies) {
for (const section of (study.sections ?? [])) {
sectionMeta[`${study.slug}--${section.id}`] = {
studyTitle: study.title,
studyDescription: typeof study.description === 'string' ? study.description : '',
title: section.title,
reference: section.reference,
studyQuestions: Array.isArray(section.studyQuestions) ? section.studyQuestions : [],
}
}
}
const studyEntries = {}
for (const [noteKey, noteText] of Object.entries(notes)) {
if (!noteText?.trim()) continue
const dashIndex = noteKey.indexOf('--')
const studySlug = dashIndex >= 0 ? noteKey.slice(0, dashIndex) : 'unknown'
const sectionId = dashIndex >= 0 ? noteKey.slice(dashIndex + 2) : noteKey
if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
studyEntries[studySlug][sectionId].noteText = noteText.trim()
}
for (const [studySlug, studyProgress] of Object.entries(progress.byStudy)) {
const quizAnswersBySection = studyProgress.quizAnswers || {}
for (const [sectionId, answers] of Object.entries(quizAnswersBySection)) {
if (!Array.isArray(answers) || answers.length === 0) continue
if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
studyEntries[studySlug][sectionId].quizAnswers = answers.filter(answer => typeof answer === 'string' && answer.trim()).map(answer => answer.trim())
}
}
const studySlugs = Array.from(new Set([
...Object.keys(studyEntries),
...Object.values(studies).map(study => study.slug),
]))
const docChildren = [
new Paragraph({ text: 'Verse by Verse with Nate', heading: HeadingLevel.TITLE }),
new Paragraph({ text: 'My Study Export', heading: HeadingLevel.HEADING_1, spacing: { after: 240 } }),
new Paragraph({ text: `Student: ${user.displayName || user.username}`, spacing: { after: 120 } }),
new Paragraph({ text: `Exported ${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`, italics: true, spacing: { after: 400 } }),
]
for (const studySlug of studySlugs) {
const study = studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
const studyTitle = study?.title || studySlug
const studyDescription = typeof study?.description === 'string' ? study.description : ''
const sectionIds = studyEntries[studySlug] ? Object.keys(studyEntries[studySlug]) : []
if (sectionIds.length === 0) continue
docChildren.push(new Paragraph({ text: studyTitle, heading: HeadingLevel.HEADING_1, spacing: { before: 400 } }))
if (studyDescription) {
docChildren.push(new Paragraph({ text: studyDescription, spacing: { after: 240 } }))
}
const noteCount = sectionIds.filter(sectionId => studyEntries[studySlug][sectionId].noteText).length
const quizCount = sectionIds.filter(sectionId => Array.isArray(studyEntries[studySlug][sectionId].quizAnswers) && studyEntries[studySlug][sectionId].quizAnswers.length > 0).length
docChildren.push(new Paragraph({ text: `Notes: ${noteCount} | Quiz sections: ${quizCount}`, italics: true, spacing: { after: 240 } }))
const orderedSectionIds = study?.sections?.map(section => section.id).filter(id => sectionIds.includes(id)) ?? sectionIds
for (const sectionId of orderedSectionIds) {
const entry = studyEntries[studySlug][sectionId]
if (!entry) continue
const meta = sectionMeta[`${studySlug}--${sectionId}`] || { title: sectionId, reference: '' }
docChildren.push(new Paragraph({ text: meta.title, heading: HeadingLevel.HEADING_2, spacing: { before: 240 } }))
if (meta.reference) {
docChildren.push(new Paragraph({ children: [new TextRun({ text: meta.reference, italics: true, color: '555555' })], spacing: { after: 120 } }))
}
if (entry.noteText) {
docChildren.push(new Paragraph({ text: 'Notes', heading: HeadingLevel.HEADING_3, spacing: { before: 120 } }))
for (const line of entry.noteText.split('\n')) {
docChildren.push(new Paragraph({ text: line.trim(), spacing: { after: 80 } }))
}
}
if (Array.isArray(meta.studyQuestions) && meta.studyQuestions.length > 0) {
docChildren.push(new Paragraph({ text: 'Quiz Questions', heading: HeadingLevel.HEADING_3, spacing: { before: 160 } }))
meta.studyQuestions.forEach((question, index) => {
docChildren.push(new Paragraph({ children: [new TextRun({ text: `${index + 1}. `, bold: true }), new TextRun({ text: question })], spacing: { after: 80 } }))
})
}
if (Array.isArray(entry.quizAnswers) && entry.quizAnswers.length > 0) {
docChildren.push(new Paragraph({ text: 'Quiz Answers', heading: HeadingLevel.HEADING_3, spacing: { before: 160 } }))
entry.quizAnswers.forEach((answer, index) => {
docChildren.push(new Paragraph({ children: [new TextRun({ text: `Answer ${index + 1}: `, bold: true }), new TextRun({ text: answer })], spacing: { after: 80 } }))
})
}
}
}
if (docChildren.length <= 4) {
docChildren.push(new Paragraph({ text: 'No notes or quiz answers saved yet.', spacing: { before: 200 } }))
}
const doc = new Document({ creator: 'Verse by Verse with Nate', title: 'My Study Export', sections: [{ children: docChildren }] })
const buffer = await Packer.toBuffer(doc)
const filename = `my-study-export-${new Date().toISOString().slice(0, 10)}.docx`
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
res.send(buffer)
})
app.post('/api/study-account/change-password', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
const user = req.studyUser
const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : ''
const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : ''
const currentHash = hashStudyPassword(currentPassword)
const a = Buffer.from(currentHash, 'utf8')
const b = Buffer.from(user.passwordHash, 'utf8')
if (a.length !== b.length || !timingSafeEqual(a, b)) {
res.status(401).json({ message: 'Current password is incorrect.' })
return
}
if (newPassword.length < 8 || newPassword.length > 200) {
res.status(400).json({ message: 'New password must be 8200 characters.' })
return
}
user.passwordHash = hashStudyPassword(newPassword)
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
res.json({ ok: true })
})
app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const notes = await loadUserNotes(user.id)
const progress = await loadUserProgress(user.id)
const noteEntries = Object.entries(notes)
const studies = getStudyCatalog().map(study => {
const totalLessons = Array.isArray(state.cachedSiteContent?.studies)
? (state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === study.slug)?.sections?.length ?? 0)
: 0
const noteCount = noteEntries.filter(([key, value]) => key.startsWith(`${study.slug}--`) && typeof value === 'string' && value.trim()).length
const completedLessons = progress.byStudy[study.slug]?.completedSectionIds?.length ?? 0
return {
slug: study.slug,
title: study.title,
status: study.status,
enrolled: isStudyUserEnrolled(user, study.slug),
totalLessons,
completedLessons,
noteCount,
}
})
res.json({
profile: {
username: user.username,
displayName: user.displayName ?? '',
subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true,
avatarUrl: getStudyAvatarUrl(user),
},
stats: {
noteCount: Object.keys(notes).length,
memberSince: user.createdAt,
lastLoginAt: user.lastLoginAt,
currentStreak: user.currentStreak ?? 0,
longestStreak: user.longestStreak ?? 0,
lastStudiedDate: user.lastStudiedDate ?? null,
},
studies,
})
})
app.post('/api/study-account/profile', requireStudyAuth, (req, res) => {
const user = req.studyUser
const displayName = typeof req.body?.displayName === 'string' ? req.body.displayName.trim().slice(0, 80) : ''
const avatarUrl = typeof req.body?.avatarUrl === 'string' ? req.body.avatarUrl.trim() : ''
if (avatarUrl && !/^https?:\/\//i.test(avatarUrl) && !avatarUrl.startsWith('/uploads/') && !avatarUrl.startsWith('data:image/')) {
res.status(400).json({ message: 'Avatar must be a valid uploaded image, data URI, or https URL.' })
return
}
if (avatarUrl.startsWith('data:image/') && avatarUrl.length > 6 * 1024 * 1024) {
res.status(400).json({ message: 'Avatar data URI is too large. Please use the upload endpoint instead.' })
return
}
user.displayName = displayName
user.avatarUrl = avatarUrl
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
res.json({ ok: true, displayName: user.displayName, avatarUrl: user.avatarUrl || getStudyAvatarUrl(user) })
})
app.post('/api/study-account/avatar-upload', requireStudyAuth, async (req, res) => {
try {
const filename = typeof req.body?.filename === 'string' ? req.body.filename : ''
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
const ext = inferImageExtensionFromDataUrl(dataUrl)
if (!ext) {
res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, or GIF data URL.' })
return
}
const base64 = dataUrl.split(',')[1] ?? ''
const buffer = Buffer.from(base64, 'base64')
if (buffer.length === 0 || buffer.length > (4 * 1024 * 1024)) {
res.status(400).json({ message: 'Upload must be between 1 byte and 4MB.' })
return
}
const isValidImageBytes = (
(buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) || // JPEG
(buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) || // PNG
(buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) || // GIF
(buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46) // WEBP (RIFF)
)
if (!isValidImageBytes) {
res.status(400).json({ message: 'Upload must be a valid PNG, JPG, WEBP, or GIF image.' })
return
}
const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, ''))
const finalName = `${baseName || 'avatar'}-${Date.now()}${ext}`
await mkdir(UPLOADS_DIR, { recursive: true })
await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
const metadata = await readUploadsMetadata()
metadata[finalName] = []
await writeUploadsMetadata(metadata)
res.json({ ok: true, url: `/uploads/${finalName}` })
} catch (err) {
console.error('[study-account-avatar-upload] upload error:', err)
res.status(500).json({ message: 'Avatar upload failed.' })
}
})
app.patch('/api/study-account/preferences', requireStudyAuth, (req, res) => {
const user = req.studyUser
const subscribeNewsletter = req.body?.subscribeNewsletter === true
const studyRemindersEnabled = req.body?.studyRemindersEnabled === true
user.subscribeNewsletter = subscribeNewsletter
user.studyRemindersEnabled = studyRemindersEnabled
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
if (subscribeNewsletter) {
syncContactToResend(user.displayName || user.username, user.username).catch(err => console.error('[study-account] resend sync error:', err))
}
res.json({ ok: true, subscribeNewsletter: user.subscribeNewsletter, studyRemindersEnabled: user.studyRemindersEnabled === true })
})
app.post('/api/study-account/request-email-change', studyAuthRateLimiter, requireStudyAuth, async (req, res) => {
const user = req.studyUser
const newEmail = normalizeStudyUsername(req.body?.newEmail)
const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : ''
if (!isValidStudyUsername(newEmail)) {
res.status(400).json({ message: 'Please enter a valid email address.' })
return
}
if (newEmail === user.username) {
res.status(400).json({ message: 'That is already your current email.' })
return
}
const existing = findStudyUserByUsername(newEmail)
if (existing && existing.id !== user.id) {
res.status(409).json({ message: 'An account with that email already exists.' })
return
}
const currentHash = hashStudyPassword(currentPassword)
const a = Buffer.from(currentHash, 'utf8')
const b = Buffer.from(user.passwordHash, 'utf8')
if (a.length !== b.length || !timingSafeEqual(a, b)) {
res.status(401).json({ message: 'Current password is incorrect.' })
return
}
const rawToken = randomUUID()
const tokenHash = hashEmailChangeToken(rawToken)
const expiresAt = Date.now() + EMAIL_CHANGE_TOKEN_TTL_MS
user.pendingEmailChange = { newEmail, tokenHash, expiresAt, requestedAt: new Date().toISOString() }
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
if (process.env.RESEND_API_KEY) {
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const baseUrl = getCanonicalBaseUrl()
const verifyUrl = buildAbsoluteUrl(baseUrl, `/study/account?verifyEmailToken=${encodeURIComponent(rawToken)}`)
const cfg = state.cachedSiteContent ?? {}
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 emailChangeCtaLabel = cfg.emailChangeCtaLabel?.trim() || 'Confirm Email Change'
const { error } = await resend.emails.send({
from: getResendFromAddress(),
to: [newEmail],
subject: emailChangeSubject,
text: `${emailChangeBody}\n\n${verifyUrl}`,
html: buildBrandedEmailHtml({
title: emailChangeSubject,
eyebrow: 'Account Security',
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) {
console.error('[study-account] email change send exception:', err)
res.status(503).json({ message: 'Could not send verification email right now.' })
return
}
}
res.json({ ok: true, verificationSent: true })
})
app.post('/api/study-account/verify-email-change', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
const user = req.studyUser
const token = typeof req.body?.token === 'string' ? req.body.token.trim() : ''
const pending = user.pendingEmailChange
if (!token || !pending || !pending.tokenHash) {
res.status(400).json({ message: 'No pending email change request found.' })
return
}
if (pending.expiresAt <= Date.now()) {
user.pendingEmailChange = null
queueStudyUsersWrite()
res.status(400).json({ message: 'This verification link has expired. Request a new email change.' })
return
}
const submittedHash = hashEmailChangeToken(token)
const a = Buffer.from(submittedHash, 'utf8')
const b = Buffer.from(pending.tokenHash, 'utf8')
if (a.length !== b.length || !timingSafeEqual(a, b)) {
res.status(400).json({ message: 'Invalid verification token.' })
return
}
const newEmail = normalizeStudyUsername(pending.newEmail)
if (!isValidStudyUsername(newEmail)) {
user.pendingEmailChange = null
queueStudyUsersWrite()
res.status(400).json({ message: 'Pending email address is invalid.' })
return
}
const existing = findStudyUserByUsername(newEmail)
if (existing && existing.id !== user.id) {
user.pendingEmailChange = null
queueStudyUsersWrite()
res.status(409).json({ message: 'An account with that email already exists.' })
return
}
user.username = newEmail
user.pendingEmailChange = null
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
if (user.subscribeNewsletter !== false) {
syncContactToResend(user.displayName || user.username, user.username).catch(err => console.error('[study-account] resend sync error:', err))
}
res.json({ ok: true, username: user.username })
})
app.get('/api/study-account/stats', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const notes = await loadUserNotes(user.id)
res.json({
noteCount: Object.keys(notes).length,
memberSince: user.createdAt,
lastLoginAt: user.lastLoginAt,
})
})
app.delete('/api/study-account', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const deletedEmail = user.username
const deletedDisplayName = user.displayName || user.username
for (const [token, session] of state.studySessions) {
if (session.userId === user.id) state.studySessions.delete(token)
}
state.studyUsers = state.studyUsers.filter(u => u.id !== user.id)
queueStudyUsersWrite()
state.studyNotesCache.delete(user.id)
try { await unlink(getUserNotesFilePath(user.id)) } catch { /* no notes file is fine */ }
state.studyProgressCache.delete(user.id)
try { await unlink(getUserProgressFilePath(user.id)) } catch { /* no progress file is fine */ }
const beforeCerts = state.studyCertificates.length
state.studyCertificates = state.studyCertificates.filter(c => c.userId !== user.id)
if (state.studyCertificates.length !== beforeCerts) queueStudyCertificatesWrite()
const beforePosts = state.studyCommunityPosts.length
state.studyCommunityPosts = state.studyCommunityPosts.filter(p => p.authorUserId !== user.id)
if (state.studyCommunityPosts.length !== beforePosts) queueStudyCommunityWrite()
const beforeComments = state.studyComments.length
state.studyComments = state.studyComments.filter(c => c.userId !== user.id)
if (state.studyComments.length !== beforeComments) queueStudyCommentsWrite()
sendStudyAccountDeletedEmail(deletedEmail, deletedDisplayName).catch(err => {
console.error('[study-account] delete email error:', err)
})
clearStudySessionCookie(res)
res.json({ ok: true })
})
}
+392
View File
@@ -0,0 +1,392 @@
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto'
import rateLimit from 'express-rate-limit'
import qrcode from 'qrcode'
import {
generateTotpSecret,
verifyTotpCode,
generateRecoveryCodes,
} from '../auth.js'
import { parseCookies } from '../helpers.js'
import { STUDY_SESSION_COOKIE, MAX_STUDY_USERS, MAX_CONTACT_SUBMISSIONS } from '../config.js'
import { state } from '../state.js'
import {
queueStudyUsersWrite,
queueContactSubmissionsWrite,
normalizeContactEmailStatus,
normalizeMessageType,
} from '../data.js'
import {
normalizeStudyUsername,
isValidStudyUsername,
hashStudyPassword,
findStudyUserByUsername,
getStudyAvatarUrl,
createStudySession,
setStudySessionCookie,
clearStudySessionCookie,
requireStudyAuth,
createStudyTotpPendingToken,
consumeStudyTotpPendingToken,
generateEmailOtp,
storeEmailOtp,
verifyEmailOtp,
getStudyUserFromRequest,
} from '../study-helpers.js'
import { sendEmailOtp, sendStudyWelcomeEmail, syncContactToResend } from '../email.js'
const studyAuthRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many attempts. Please wait 15 minutes and try again.' },
skipSuccessfulRequests: true,
})
export function register(app) {
app.get('/api/study-auth/status', (req, res) => {
const user = getStudyUserFromRequest(req)
res.json({
authenticated: Boolean(user),
username: user?.username ?? '',
displayName: user?.displayName ?? '',
subscribeNewsletter: user?.subscribeNewsletter !== false,
studyRemindersEnabled: user?.studyRemindersEnabled === true,
avatarUrl: user ? getStudyAvatarUrl(user) : '',
enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [],
totpEnabled: Boolean(user && (user.twoFaMethod === 'app' || user.twoFaMethod === 'email') && (user.twoFaMethod === 'email' || (user.totpSecret && user.totpVerified))),
twoFaMethod: user?.twoFaMethod ?? null,
totpRecoveryCodesRemaining: user?.twoFaMethod === 'app' ? (user.totpRecoveryCodes?.length ?? 0) : 0,
})
})
app.post('/api/study-auth/signup', studyAuthRateLimiter, async (req, res) => {
const username = normalizeStudyUsername(req.body?.username)
const password = typeof req.body?.password === 'string' ? req.body.password : ''
const subscribe = req.body?.subscribe === true
const displayName = typeof req.body?.displayName === 'string' ? req.body.displayName.trim().slice(0, 80) : ''
if (!isValidStudyUsername(username)) {
res.status(400).json({ message: 'Please enter a valid email address.' })
return
}
if (typeof password !== 'string' || password.length < 8 || password.length > 200) {
res.status(400).json({ message: 'Password must be 8-200 characters.' })
return
}
if (findStudyUserByUsername(username)) {
res.status(409).json({ message: 'An account with that email already exists.' })
return
}
if (state.studyUsers.length >= MAX_STUDY_USERS) {
res.status(503).json({ message: 'Account registration is temporarily unavailable. Please try again later.' })
return
}
const now = new Date().toISOString()
const user = {
id: randomUUID(),
username,
passwordHash: hashStudyPassword(password),
displayName,
subscribeNewsletter: subscribe,
studyRemindersEnabled: false,
pendingEmailChange: null,
enrolledStudySlugs: [],
createdAt: now,
updatedAt: now,
lastLoginAt: now,
}
state.studyUsers.push(user)
queueStudyUsersWrite()
if (subscribe) {
const wantsWelcome = true
const submission = {
id: randomUUID(),
submittedAt: now,
name: displayName || username,
email: username,
message: '',
messageType: 'general',
subscribe: wantsWelcome,
archived: false,
emailStatus: normalizeContactEmailStatus(null, wantsWelcome),
}
state.contactSubmissions.unshift(submission)
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
queueContactSubmissionsWrite()
syncContactToResend(displayName || username, username).catch(err => console.error('[study-signup] resend sync error:', err))
}
sendStudyWelcomeEmail(username, displayName || username).catch(err => console.error('[study-signup] welcome email error:', err))
const sessionToken = createStudySession(user.id)
setStudySessionCookie(res, sessionToken)
res.json({
ok: true,
username: user.username,
displayName: user.displayName,
subscribeNewsletter: user.subscribeNewsletter,
studyRemindersEnabled: user.studyRemindersEnabled === true,
avatarUrl: getStudyAvatarUrl(user.username),
enrolledStudySlugs: user.enrolledStudySlugs,
})
})
app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => {
const username = normalizeStudyUsername(req.body?.username)
const password = typeof req.body?.password === 'string' ? req.body.password : ''
const user = findStudyUserByUsername(username)
if (!user) {
res.status(401).json({ message: 'Invalid email or password.' })
return
}
const submittedHash = hashStudyPassword(password)
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.' })
return
}
const twoFaMethod = user.twoFaMethod ?? (user.totpSecret && user.totpVerified ? 'app' : null)
if (twoFaMethod === 'app' && user.totpSecret && user.totpVerified) {
const pendingToken = createStudyTotpPendingToken(user.id)
res.json({ totpRequired: true, pendingToken, method: 'app' })
return
}
if (twoFaMethod === 'email') {
const code = generateEmailOtp()
storeEmailOtp(user.id, code)
const pendingToken = createStudyTotpPendingToken(user.id)
sendEmailOtp(user.username, code).catch(err => console.error('[email-otp] login send error:', err))
res.json({ totpRequired: true, pendingToken, method: 'email' })
return
}
user.lastLoginAt = new Date().toISOString()
user.updatedAt = user.lastLoginAt
user.reengagementSentAt = {}
queueStudyUsersWrite()
const sessionToken = createStudySession(user.id)
setStudySessionCookie(res, sessionToken)
res.json({
ok: true,
username: user.username,
displayName: user.displayName ?? '',
subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true,
avatarUrl: getStudyAvatarUrl(user.username),
enrolledStudySlugs: user.enrolledStudySlugs ?? [],
})
})
app.post('/api/study-auth/totp-verify', studyAuthRateLimiter, (req, res) => {
const { pendingToken, code } = req.body ?? {}
const userId = consumeStudyTotpPendingToken(pendingToken)
if (!userId) {
res.status(401).json({ message: 'Session expired or invalid. Please sign in again.' })
return
}
const user = state.studyUsers.find(u => u.id === userId)
const twoFaMethodCheck = user?.twoFaMethod ?? (user?.totpSecret && user?.totpVerified ? 'app' : null)
if (!user || !twoFaMethodCheck) {
res.status(400).json({ message: '2FA is not configured for this account.' })
return
}
const codeStr = typeof code === 'string' ? code.replace(/\s/g, '') : ''
const twoFaMethod = twoFaMethodCheck
function completeLogin(extra = {}) {
user.lastLoginAt = new Date().toISOString()
user.updatedAt = user.lastLoginAt
user.reengagementSentAt = {}
queueStudyUsersWrite()
const sessionToken = createStudySession(user.id)
setStudySessionCookie(res, sessionToken)
res.json({ ok: true, ...extra, username: user.username, displayName: user.displayName ?? '', subscribeNewsletter: user.subscribeNewsletter !== false, studyRemindersEnabled: user.studyRemindersEnabled === true, avatarUrl: getStudyAvatarUrl(user.username), enrolledStudySlugs: user.enrolledStudySlugs ?? [] })
}
if (twoFaMethod === 'email') {
const result = verifyEmailOtp(user.id, codeStr)
if (result === 'ok') { completeLogin(); return }
if (result === 'expired') { res.status(401).json({ message: 'Code expired. Please sign in again to receive a new code.' }); return }
if (result === 'too-many') { res.status(401).json({ message: 'Too many attempts. Please sign in again.' }); return }
res.status(401).json({ message: 'Invalid code. Check your email and try again.' })
return
}
if (verifyTotpCode(user.totpSecret, codeStr)) {
completeLogin()
return
}
if (Array.isArray(user.totpRecoveryCodes) && user.totpRecoveryCodes.length > 0) {
const normalised = codeStr.replace(/-/g, '').toUpperCase()
const matchIdx = user.totpRecoveryCodes.findIndex(h => {
try { return createHash('sha256').update(normalised).digest('hex') === h } catch { return false }
})
if (matchIdx !== -1) {
user.totpRecoveryCodes.splice(matchIdx, 1)
completeLogin({ usedRecoveryCode: true, remainingRecoveryCodes: user.totpRecoveryCodes.length })
return
}
}
res.status(401).json({ message: 'Invalid code. Try again or use a recovery code.' })
})
app.post('/api/study-auth/totp-setup-init', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const secret = generateTotpSecret()
const label = user.username
const issuer = 'Verse by Verse with Nate'
const uri = `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(label)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`
const qrDataUrl = await qrcode.toDataURL(uri)
user.totpSecretPending = secret
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
res.json({ qrDataUrl, secret })
})
app.post('/api/study-auth/totp-setup-confirm', requireStudyAuth, (req, res) => {
const user = req.studyUser
const { code } = req.body ?? {}
if (!user.totpSecretPending) {
res.status(400).json({ message: 'No 2FA setup in progress. Start setup first.' })
return
}
if (!verifyTotpCode(user.totpSecretPending, typeof code === 'string' ? code.replace(/\s/g, '') : '')) {
res.status(401).json({ message: 'Code incorrect. Scan the QR code again and try once more.' })
return
}
const recoveryCodes = generateRecoveryCodes()
user.totpSecret = user.totpSecretPending
user.totpVerified = true
user.twoFaMethod = 'app'
user.totpEnabledAt = new Date().toISOString()
user.totpRecoveryCodes = recoveryCodes.map(c => createHash('sha256').update(c.replace(/-/g, '').toUpperCase()).digest('hex'))
delete user.totpSecretPending
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
res.json({ ok: true, recoveryCodes })
})
app.post('/api/study-auth/2fa-setup-email', studyAuthRateLimiter, requireStudyAuth, async (req, res) => {
const user = req.studyUser
const code = generateEmailOtp()
storeEmailOtp(user.id, code)
await sendEmailOtp(user.username, code)
res.json({ ok: true })
})
app.post('/api/study-auth/2fa-setup-email-confirm', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
const user = req.studyUser
const { code } = req.body ?? {}
const result = verifyEmailOtp(user.id, typeof code === 'string' ? code.trim() : '')
if (result === 'expired') { res.status(401).json({ message: 'Code expired. Please request a new one.' }); return }
if (result === 'too-many') { res.status(401).json({ message: 'Too many attempts. Please request a new code.' }); return }
if (result !== 'ok') { res.status(401).json({ message: 'Invalid code. Check your email and try again.' }); return }
user.twoFaMethod = 'email'
user.totpSecret = null
user.totpVerified = false
user.totpRecoveryCodes = []
delete user.totpSecretPending
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
res.json({ ok: true })
})
app.post('/api/study-auth/email-otp-resend', studyAuthRateLimiter, async (req, res) => {
const { pendingToken } = req.body ?? {}
const entry = state.studyTotpPendingTokens.get(pendingToken)
if (!entry || Date.now() > entry.expiresAt) { res.status(401).json({ message: 'Session expired. Please sign in again.' }); return }
const user = state.studyUsers.find(u => u.id === entry.userId)
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
const code = generateEmailOtp()
storeEmailOtp(user.id, code)
await sendEmailOtp(user.username, code)
res.json({ ok: true })
})
app.post('/api/study-auth/totp-disable', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
const user = req.studyUser
const { password } = req.body ?? {}
const submittedHash = hashStudyPassword(typeof password === 'string' ? password : '')
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.' })
return
}
user.twoFaMethod = null
user.totpSecret = null
user.totpVerified = false
user.totpRecoveryCodes = []
delete user.totpSecretPending
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
res.json({ ok: true })
})
app.post('/api/study-auth/totp-regen-recovery', requireStudyAuth, (req, res) => {
const user = req.studyUser
if (!user.totpSecret || !user.totpVerified) {
res.status(400).json({ message: '2FA is not enabled.' })
return
}
const recoveryCodes = generateRecoveryCodes()
user.totpRecoveryCodes = recoveryCodes.map(c => createHash('sha256').update(c.replace(/-/g, '').toUpperCase()).digest('hex'))
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
res.json({ ok: true, recoveryCodes })
})
app.post('/api/study-auth/logout', (req, res) => {
const cookies = parseCookies(req.headers.cookie)
const token = cookies[STUDY_SESSION_COOKIE]
if (token) {
state.studySessions.delete(token)
}
clearStudySessionCookie(res)
res.json({ ok: true })
})
// One-click unsubscribe from re-engagement emails (no login required)
app.get('/api/study-auth/unsubscribe-reminders', (req, res) => {
const { uid, token } = req.query ?? {}
if (typeof uid !== 'string' || typeof token !== 'string') {
res.status(400).send('Invalid unsubscribe link.')
return
}
const secret = process.env.RESEND_WEBHOOK_TOKEN ?? 'siteforge'
const expected = createHmac('sha256', secret).update(uid).digest('hex')
const expectedBuf = Buffer.from(expected, 'hex')
const actualBuf = Buffer.from(token.length === expected.length ? token : '', 'hex')
let valid = false
try { valid = timingSafeEqual(expectedBuf, actualBuf) } catch { valid = false }
if (!valid) {
res.status(400).send('Invalid or expired unsubscribe link.')
return
}
const user = state.studyUsers.find(u => u.id === uid)
if (!user) {
res.status(404).send('Account not found.')
return
}
user.studyRemindersEnabled = false
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
res.send('<!doctype html><html><head><meta charset="utf-8"><title>Unsubscribed</title><style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:24px;text-align:center;color:#333}h1{font-size:1.4rem;margin-bottom:12px}p{color:#666;line-height:1.6}</style></head><body><h1>You\'ve been unsubscribed</h1><p>You will no longer receive re-engagement reminder emails. You can re-enable them anytime from your account settings.</p></body></html>')
})
}
+124
View File
@@ -0,0 +1,124 @@
import { randomUUID } from 'node:crypto'
import rateLimit from 'express-rate-limit'
import { requireAdminAuth } from '../auth.js'
import { requireStudyAuth, normalizeStudySlug, isStudyUserEnrolled, getStudyTitleBySlug } from '../study-helpers.js'
import { state } from '../state.js'
import { loadUserProgress, queueStudyCertificatesWrite } from '../data.js'
const publicCertRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
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) {
// GET — check eligibility and return existing certificate token if one exists
app.get('/api/study-certificate/:studySlug', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return }
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Not enrolled in this study.' }); return
}
const requiredIds = getRequiredSectionIds(studySlug)
if (!requiredIds || requiredIds.length === 0) {
res.status(404).json({ message: 'Study sections not found.' }); return
}
const progress = await loadUserProgress(user.id)
const completedIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
const eligible = requiredIds.every(id => completedIds.includes(id))
const existing = state.studyCertificates.find(c => c.userId === user.id && c.studySlug === studySlug)
res.json({
eligible,
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
completedCount: completedIds.filter(id => requiredIds.includes(id)).length,
totalCount: requiredIds.length,
token: existing?.token ?? null,
issuedAt: existing?.issuedAt ?? null,
})
})
// POST — issue/re-issue a certificate (must be eligible)
app.post('/api/study-certificate/:studySlug', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return }
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Not enrolled in this study.' }); return
}
const requiredIds = getRequiredSectionIds(studySlug)
if (!requiredIds || requiredIds.length === 0) {
res.status(404).json({ message: 'Study sections not found.' }); return
}
const progress = await loadUserProgress(user.id)
const completedIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
const eligible = requiredIds.every(id => completedIds.includes(id))
if (!eligible) {
res.status(403).json({ message: 'Complete all sections to earn your certificate.' }); return
}
// Find or create
let cert = state.studyCertificates.find(c => c.userId === user.id && c.studySlug === studySlug)
if (!cert) {
cert = {
id: randomUUID(),
token: randomUUID(),
userId: user.id,
studySlug,
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
displayName: user.displayName || user.username,
issuedAt: new Date().toISOString(),
}
state.studyCertificates.push(cert)
queueStudyCertificatesWrite()
}
res.json({ ok: true, token: cert.token, issuedAt: cert.issuedAt })
})
// GET — public certificate page data (no auth, by token)
app.get('/api/public/certificate/:token', publicCertRateLimiter, (req, res) => {
const { token } = req.params
if (!token || typeof token !== 'string' || token.length > 100) {
res.status(400).json({ message: 'Invalid token.' }); return
}
const cert = state.studyCertificates.find(c => c.token === token)
if (!cert) { res.status(404).json({ message: 'Certificate not found.' }); return }
res.json({
studyTitle: cert.studyTitle,
displayName: cert.displayName,
issuedAt: cert.issuedAt,
})
})
// Admin: list all certificates
app.get('/api/admin-study-certificates', requireAdminAuth, (_req, res) => {
res.json({ certificates: state.studyCertificates })
})
}
+122
View File
@@ -0,0 +1,122 @@
import { randomUUID } from 'node:crypto'
import { requireAdminAuth } from '../auth.js'
import { requireStudyAuth, normalizeStudySlug, normalizeLessonSectionId, isStudyUserEnrolled } from '../study-helpers.js'
import { state } from '../state.js'
import { queueStudyCommentsWrite } from '../data.js'
import { MAX_STUDY_COMMENTS, MAX_STUDY_COMMENT_LENGTH } from '../config.js'
export function register(app) {
// GET public-approved comments for a section (no auth)
app.get('/api/study-comments/:studySlug/:sectionId', (req, res) => {
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
}
const comments = state.studyComments
.filter(c => c.studySlug === studySlug && c.sectionId === sectionId && c.isApproved === true)
.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
.map(c => ({
id: c.id,
displayName: c.displayName,
text: c.text,
createdAt: c.createdAt,
}))
res.json({ comments })
})
// POST — enrolled study users post a comment (auto-approved for enrolled users)
app.post('/api/study-comments/:studySlug/:sectionId', requireStudyAuth, (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 (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to leave a comment.' }); return
}
const text = typeof req.body?.text === 'string' ? req.body.text.trim() : ''
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
}
// Auto-approve for enrolled users
const now = new Date().toISOString()
const comment = {
id: randomUUID(),
studySlug,
sectionId,
userId: user.id,
displayName: user.displayName || user.username,
text,
createdAt: now,
isApproved: true,
approvedAt: now,
}
state.studyComments.unshift(comment)
if (state.studyComments.length > MAX_STUDY_COMMENTS) {
state.studyComments = state.studyComments.slice(0, MAX_STUDY_COMMENTS)
}
queueStudyCommentsWrite()
res.status(201).json({
ok: true,
comment: {
id: comment.id,
displayName: comment.displayName,
text: comment.text,
createdAt: comment.createdAt,
},
})
})
// DELETE — enrolled users can delete their own comment
app.delete('/api/study-comments/:commentId', requireStudyAuth, (req, res) => {
const user = req.studyUser
const { commentId } = req.params
const idx = state.studyComments.findIndex(c => c.id === commentId && c.userId === user.id)
if (idx === -1) {
res.status(404).json({ message: 'Comment not found.' }); return
}
state.studyComments.splice(idx, 1)
queueStudyCommentsWrite()
res.json({ ok: true })
})
// Admin: list all comments (optionally filter by studySlug)
app.get('/api/admin-study-comments', requireAdminAuth, (req, res) => {
const { studySlug, sectionId, approved } = req.query
let comments = state.studyComments
if (studySlug) comments = comments.filter(c => c.studySlug === studySlug)
if (sectionId) comments = comments.filter(c => c.sectionId === sectionId)
if (approved === 'true') comments = comments.filter(c => c.isApproved === true)
if (approved === 'false') comments = comments.filter(c => c.isApproved !== true)
res.json({ comments: comments.slice(0, 500) })
})
// Admin: approve a comment
app.post('/api/admin-study-comments/:commentId/approve', requireAdminAuth, (req, res) => {
const comment = state.studyComments.find(c => c.id === req.params.commentId)
if (!comment) { res.status(404).json({ message: 'Comment not found.' }); return }
comment.isApproved = true
comment.approvedAt = new Date().toISOString()
queueStudyCommentsWrite()
res.json({ ok: true, comment })
})
// Admin: delete any comment
app.delete('/api/admin-study-comments/:commentId', requireAdminAuth, (req, res) => {
const idx = state.studyComments.findIndex(c => c.id === req.params.commentId)
if (idx === -1) { res.status(404).json({ message: 'Comment not found.' }); return }
state.studyComments.splice(idx, 1)
queueStudyCommentsWrite()
res.json({ ok: true })
})
}
+415
View File
@@ -0,0 +1,415 @@
import {
requireStudyAuth,
normalizeStudySlug,
normalizeLessonSectionId,
getStudySlugFromNoteId,
isStudyUserEnrolled,
isEnrollableStudySlug,
getStudyTitleBySlug,
findStudyUserById,
getStudyAvatarUrl,
getStudyCatalog,
} from '../study-helpers.js'
import { state } from '../state.js'
import {
queueStudyUsersWrite,
queueStudyCommunityWrite,
loadUserNotes,
queueUserNotesWrite,
loadUserProgress,
queueUserProgressWrite,
sanitizeStudyCommunityPosts,
} from '../data.js'
import { MAX_STUDY_NOTE_LENGTH, MAX_STUDY_NOTES_PER_USER, MAX_STUDY_ENROLLMENTS_PER_USER } from '../config.js'
import { randomUUID } from 'node:crypto'
const noteSaveHits = new Map() // userId -> { count, windowStart }
const NOTE_RATE_WINDOW_MS = 60 * 1000
const NOTE_RATE_MAX = 30
function checkNoteSaveRateLimit(userId) {
const now = Date.now()
const entry = noteSaveHits.get(userId) ?? { count: 0, windowStart: now }
if (now - entry.windowStart > NOTE_RATE_WINDOW_MS) { entry.count = 0; entry.windowStart = now }
entry.count += 1
noteSaveHits.set(userId, entry)
return entry.count <= NOTE_RATE_MAX
}
export function register(app) {
// ── Enrollment ────────────────────────────────────────────────────────────
app.get('/api/study-enrollment', requireStudyAuth, (req, res) => {
const user = req.studyUser
res.json({
enrolledStudySlugs: Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [],
availableStudies: getStudyCatalog()
.map(study => ({ slug: study.slug, title: study.title })),
})
})
app.post('/api/study-enrollment/:studySlug', requireStudyAuth, (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
if (!studySlug || !isEnrollableStudySlug(studySlug)) {
res.status(404).json({ message: 'Study not found.' })
return
}
const enrolled = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
if (!enrolled.includes(studySlug)) {
user.enrolledStudySlugs = [...enrolled, studySlug].slice(0, MAX_STUDY_ENROLLMENTS_PER_USER)
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
}
res.json({
ok: true,
studySlug,
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
enrolledStudySlugs: user.enrolledStudySlugs,
})
})
app.delete('/api/study-enrollment/:studySlug', requireStudyAuth, (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
if (!studySlug || !isEnrollableStudySlug(studySlug)) {
res.status(404).json({ message: 'Study not found.' })
return
}
const enrolled = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
if (enrolled.includes(studySlug)) {
user.enrolledStudySlugs = enrolled.filter(slug => slug !== studySlug)
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
}
res.json({
ok: true,
studySlug,
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
enrolledStudySlugs: user.enrolledStudySlugs,
})
})
// ── Notes ─────────────────────────────────────────────────────────────────
app.get('/api/study-notes', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const notes = await loadUserNotes(user.id)
res.json({ notes })
})
app.get('/api/study-notes/:sectionId', requireStudyAuth, async (req, res) => {
const sectionId = normalizeLessonSectionId(req.params.sectionId)
if (!sectionId) {
res.status(400).json({ message: 'Invalid section id.' })
return
}
const user = req.studyUser
const noteStudySlug = getStudySlugFromNoteId(sectionId)
if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) {
res.status(403).json({ message: 'Please enroll in this study to access notes.' })
return
}
const notes = await loadUserNotes(user.id)
res.json({ note: notes[sectionId] ?? '' })
})
app.put('/api/study-notes/:sectionId', requireStudyAuth, async (req, res) => {
const sectionId = normalizeLessonSectionId(req.params.sectionId)
if (!sectionId) {
res.status(400).json({ message: 'Invalid section id.' })
return
}
const user = req.studyUser
if (!checkNoteSaveRateLimit(user.id)) {
res.status(429).json({ message: 'Too many note saves. Please slow down.' })
return
}
const noteStudySlug = getStudySlugFromNoteId(sectionId)
if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) {
res.status(403).json({ message: 'Please enroll in this study to save notes.' })
return
}
const rawNote = typeof req.body?.note === 'string' ? req.body.note : ''
const note = rawNote.trim().slice(0, MAX_STUDY_NOTE_LENGTH)
const notes = await loadUserNotes(user.id)
if (!note) {
delete notes[sectionId]
} else {
const existingCount = Object.keys(notes).length
if (!notes[sectionId] && existingCount >= MAX_STUDY_NOTES_PER_USER) {
res.status(400).json({ message: 'Notes limit reached for this account.' })
return
}
notes[sectionId] = note
}
state.studyNotesCache.set(user.id, notes)
queueUserNotesWrite(user.id)
res.json({ ok: true, note })
})
// ── Progress ──────────────────────────────────────────────────────────────
app.get('/api/study-progress/:studySlug', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
if (!studySlug) {
res.status(400).json({ message: 'Study slug is required.' })
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to view progress.' })
return
}
const progress = await loadUserProgress(user.id)
const completedSectionIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
// Record first visit timestamp on the user record (additive, never overwrite)
if (!user.firstVisitAt) {
user.firstVisitAt = new Date().toISOString()
queueStudyUsersWrite()
}
res.json({ studySlug, completedSectionIds })
})
app.post('/api/study-progress/:studySlug/:sectionId', 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 (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to update progress.' })
return
}
const progress = await loadUserProgress(user.id)
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] }
if (!studyProgress.completedSectionIds.includes(sectionId)) {
studyProgress.completedSectionIds = [...studyProgress.completedSectionIds, sectionId]
}
progress.byStudy[studySlug] = studyProgress
progress.updatedAt = new Date().toISOString()
state.studyProgressCache.set(user.id, progress)
queueUserProgressWrite(user.id)
// Streak tracking
const todayStr = new Date().toISOString().slice(0, 10)
const lastDate = user.lastStudiedDate ?? null
if (lastDate !== todayStr) {
const yesterday = new Date(Date.now() - 864e5).toISOString().slice(0, 10)
user.currentStreak = lastDate === yesterday ? (user.currentStreak ?? 0) + 1 : 1
user.longestStreak = Math.max(user.currentStreak, user.longestStreak ?? 0)
user.lastStudiedDate = todayStr
}
// Record first completion timestamp on the user record (additive, never overwrite)
if (!user.firstCompletionAt) {
user.firstCompletionAt = new Date().toISOString()
}
queueStudyUsersWrite()
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds })
})
app.delete('/api/study-progress/:studySlug/:sectionId', 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 (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to update progress.' })
return
}
const progress = await loadUserProgress(user.id)
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] }
studyProgress.completedSectionIds = studyProgress.completedSectionIds.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, completedSectionIds: studyProgress.completedSectionIds })
})
// ── Quiz ──────────────────────────────────────────────────────────────────
app.get('/api/study-quiz/:studySlug/:sectionId', 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 (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to view quiz answers.' })
return
}
const progress = await loadUserProgress(user.id)
const quizAnswers = progress.byStudy[studySlug]?.quizAnswers?.[sectionId] ?? []
res.json({ studySlug, sectionId, answers: quizAnswers })
})
app.post('/api/study-quiz/:studySlug/:sectionId', 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 (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to save quiz answers.' })
return
}
const rawAnswers = req.body?.answers
const answers = Array.isArray(rawAnswers)
? rawAnswers.map(answer => typeof answer === 'string' ? answer.trim() : '').filter(Boolean)
: []
const progress = await loadUserProgress(user.id)
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] }
studyProgress.quizAnswers = studyProgress.quizAnswers || {}
studyProgress.quizAnswers[sectionId] = answers
progress.byStudy[studySlug] = studyProgress
progress.updatedAt = new Date().toISOString()
state.studyProgressCache.set(user.id, progress)
queueUserProgressWrite(user.id)
res.json({ ok: true, studySlug, sectionId, answers })
})
// ── Community ─────────────────────────────────────────────────────────────
app.get('/api/study-community', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(typeof req.query?.studySlug === 'string' ? req.query.studySlug : '')
if (!studySlug) {
res.status(400).json({ message: 'Study slug is required.' })
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to view the community.' })
return
}
const posts = state.studyCommunityPosts
.filter(post => post.studySlug === studySlug)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 50)
.map(post => {
const author = findStudyUserById(post.authorUserId)
return {
...post,
authorAvatarUrl: getStudyAvatarUrl(author || post.authorName || ''),
replies: Array.isArray(post.replies)
? post.replies.map(reply => {
const replyAuthor = findStudyUserById(reply.authorUserId)
return { ...reply, authorAvatarUrl: getStudyAvatarUrl(replyAuthor || reply.authorName || '') }
})
: [],
}
})
res.json({ studySlug, posts })
})
app.post('/api/study-community/posts', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.body?.studySlug)
const sectionId = typeof req.body?.sectionId === 'string' && /^[a-z0-9-]{1,80}$/i.test(req.body.sectionId) ? req.body.sectionId.trim() : ''
const message = typeof req.body?.message === 'string' ? req.body.message.trim().slice(0, 3000) : ''
if (!studySlug || !message) {
res.status(400).json({ message: 'Study slug and message are required.' })
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to post in the community.' })
return
}
const now = new Date().toISOString()
const authorName = user.displayName?.trim() || (user.username?.includes('@') ? user.username.split('@')[0] : user.username)
const post = {
id: randomUUID(),
studySlug,
sectionId,
authorUserId: user.id,
authorName,
authorAvatarUrl: getStudyAvatarUrl(user.username),
message,
createdAt: now,
replies: [],
}
state.studyCommunityPosts.unshift(post)
state.studyCommunityPosts = sanitizeStudyCommunityPosts(state.studyCommunityPosts).slice(0, 500)
queueStudyCommunityWrite()
res.json({ ok: true, post })
})
app.post('/api/study-community/posts/:postId/replies', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const postId = typeof req.params.postId === 'string' ? req.params.postId.trim() : ''
const message = typeof req.body?.message === 'string' ? req.body.message.trim().slice(0, 3000) : ''
if (!postId || !message) {
res.status(400).json({ message: 'Post id and message are required.' })
return
}
const post = state.studyCommunityPosts.find(item => item.id === postId)
if (!post) {
res.status(404).json({ message: 'Post not found.' })
return
}
if (!isStudyUserEnrolled(user, post.studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to reply in the community.' })
return
}
const reply = {
id: randomUUID(),
authorUserId: user.id,
authorName: user.displayName?.trim() || (user.username?.includes('@') ? user.username.split('@')[0] : user.username),
authorAvatarUrl: getStudyAvatarUrl(user.username),
message,
createdAt: new Date().toISOString(),
}
post.replies = Array.isArray(post.replies) ? post.replies : []
post.replies.push(reply)
post.replies = sanitizeStudyCommunityPosts([post])[0]?.replies ?? []
queueStudyCommunityWrite()
res.json({ ok: true, reply })
})
}
+122
View File
@@ -0,0 +1,122 @@
import {
EMPTY_HIT_STATS,
EMPTY_VISITOR_STATS,
DEFAULT_PUBLISH_STATE,
DEFAULT_REPLY_TEMPLATES,
buildDefaultPodcastChecklist,
} from './config.js'
export const state = {
cachedSiteContent: null,
cachedDraftSiteContent: null,
publishState: { ...DEFAULT_PUBLISH_STATE },
hitStats: { ...EMPTY_HIT_STATS },
hitStatsWritePromise: Promise.resolve(),
lastHitStatsWrite: { ok: true, at: null, error: null },
visitorStats: { ...EMPTY_VISITOR_STATS },
visitorStatsWritePromise: Promise.resolve(),
lastVisitorStatsWrite: { ok: true, at: null, error: null },
contactSubmissions: [],
contactSubmissionsWritePromise: Promise.resolve(),
questions: [],
questionsWritePromise: Promise.resolve(),
draftQuestions: null,
draftQuestionsWritePromise: Promise.resolve(),
replyTemplates: [...DEFAULT_REPLY_TEMPLATES],
replyTemplatesWritePromise: Promise.resolve(),
replyHistory: [],
replyHistoryWritePromise: Promise.resolve(),
podcastChecklist: buildDefaultPodcastChecklist(),
podcastChecklistWritePromise: Promise.resolve(),
studyUsers: [],
studyUsersWritePromise: Promise.resolve(),
studyCommunityPosts: [],
studyCommunityWritePromise: Promise.resolve(),
studyReminders: { users: {}, updatedAt: new Date().toISOString() },
studyRemindersWritePromise: Promise.resolve(),
studyComments: [],
studyCommentsWritePromise: Promise.resolve(),
studyCertificates: [],
studyCertificatesWritePromise: Promise.resolve(),
// episodeScripts: { [episodeNumber]: { text, title, filename, uploadedAt } }
episodeScripts: {},
episodeScriptsWritePromise: Promise.resolve(),
downloadCounts: {},
downloadCountsWritePromise: Promise.resolve(),
// episodePlays: { [title]: { total: number, byDay: { [YYYY-MM-DD]: number } } }
episodePlays: {},
episodePlaysWritePromise: Promise.resolve(),
// analyticsEvents: aggregated engagement data from frontend events
// {
// scrollDepth: { [path]: { 25: n, 50: n, 75: n, 90: n } }
// timeOnPage: { [path]: { totalSeconds: n, count: n } }
// outboundClicks: { [url]: n }
// utmSources: { [utm_source]: n }
// searchQueries: { [query]: n }
// notFound: { [path]: n }
// audioEvents: { [title]: { pauses: n, completions: n, totalListenSeconds: n } }
// }
analyticsEvents: {
scrollDepth: {},
timeOnPage: {},
outboundClicks: {},
utmSources: {},
searchQueries: {},
notFound: {},
audioEvents: {},
},
analyticsEventsWritePromise: Promise.resolve(),
// qrCodes: Array<{ id, slug, label, destination, createdAt }>
// qrScans: Array<{ id, qrId, slug, scannedAt, ip, userAgent }>
qrCodes: [],
qrScans: [],
qrCodesWritePromise: Promise.resolve(),
emailSettings: { signature: 'Grace and peace,\nVerse by Verse with Nate' },
emailSettingsWritePromise: Promise.resolve(),
calendarEvents: [],
calendarEventsWritePromise: Promise.resolve(),
auditLog: [],
auditLogWritePromise: Promise.resolve(),
webhooks: [],
webhooksWritePromise: Promise.resolve(),
lastBackupStatus: { ok: true, at: null, error: null, file: null },
lastCachePurgeStatus: { ok: true, at: null, error: null },
lastDeployHookStatus: { ok: true, at: null, error: null },
// In-memory caches (not persisted between restarts)
studyNotesCache: new Map(), // userId -> { [sectionId]: string }
studyNotesWriteQueues: new Map(), // userId -> Promise
studyProgressCache: new Map(), // userId -> { byStudy: ... }
studyProgressWriteQueues: new Map(), // userId -> Promise
studySessions: new Map(),
studyTotpPendingTokens: new Map(),
emailOtpStore: new Map(),
titusDownloadTokens: new Map(),
contactSubmitCooldownByEmail: new Map(),
resendEmailSubmissionIndex: new Map(),
}
+735
View File
@@ -0,0 +1,735 @@
import { createHash, createHmac, randomUUID } from 'node:crypto'
import path from 'node:path'
import { parseCookies, cookieSecureFlag } from './helpers.js'
import {
STUDY_SESSION_COOKIE,
STUDY_SESSION_TTL_MS,
STUDY_TOTP_PENDING_TTL_MS,
EMAIL_OTP_TTL_MS,
EMAIL_OTP_MAX_ATTEMPTS,
CONTACT_EMAIL_COOLDOWN_MS,
DEFAULT_REDIRECT_RULES,
} from './config.js'
import { state } from './state.js'
import { queueStudyRemindersWrite, queueStudyUsersWrite } from './data.js'
// ── Username / slug normalizers ────────────────────────────────────────────
export function normalizeStudyUsername(value) {
if (typeof value !== 'string') return ''
return value.trim().toLowerCase()
}
export function normalizeStudySlug(value) {
if (typeof value !== 'string') return ''
const trimmed = value.trim().toLowerCase()
return /^[a-z0-9-]{1,80}$/.test(trimmed) ? trimmed : ''
}
export function isValidStudyUsername(value) {
return /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(value) && value.length <= 254
}
export function normalizeLessonSectionId(value) {
if (typeof value !== 'string') return ''
const trimmed = value.trim().toLowerCase()
return /^[a-z0-9-]{1,80}$/.test(trimmed) ? trimmed : ''
}
export function getStudySlugFromNoteId(sectionId) {
if (typeof sectionId !== 'string') return ''
const separatorIndex = sectionId.indexOf('--')
if (separatorIndex <= 0) return ''
return normalizeStudySlug(sectionId.slice(0, separatorIndex))
}
// ── Password / token hashing ───────────────────────────────────────────────
export function hashStudyPassword(password) {
return createHash('sha256').update(`study-user:${String(password)}`).digest('hex')
}
export function hashEmailChangeToken(token) {
return createHash('sha256').update(`study-email-change:${String(token)}`).digest('hex')
}
// ── Catalog / enrollment helpers ───────────────────────────────────────────
export function getStudyCatalog() {
const fallback = [
{ slug: 'colossians', title: 'Colossians: Rooted in Christ', status: 'active' },
]
const content = state.cachedSiteContent
if (!content || typeof content !== 'object') return fallback
if (Array.isArray(content.studies) && content.studies.length > 0) {
const out = []
const seen = new Set()
for (const study of content.studies) {
const slug = normalizeStudySlug(study?.slug)
if (!slug || seen.has(slug)) continue
seen.add(slug)
out.push({
slug,
title: typeof study?.title === 'string' && study.title.trim() ? study.title.trim() : slug,
status: study?.status === 'planned' ? 'planned' : 'active',
})
}
if (out.length > 0) return out
}
return fallback
}
export function isEnrollableStudySlug(studySlug) {
const normalized = normalizeStudySlug(studySlug)
if (!normalized) return false
return getStudyCatalog().some(study => study.slug === normalized)
}
export function getStudyTitleBySlug(studySlug) {
const normalized = normalizeStudySlug(studySlug)
if (!normalized) return ''
const study = getStudyCatalog().find(item => item.slug === normalized)
return study?.title ?? ''
}
export function isStudyUserEnrolled(user, studySlug) {
const normalized = normalizeStudySlug(studySlug)
if (!normalized || !user) return false
return Array.isArray(user.enrolledStudySlugs) && user.enrolledStudySlugs.includes(normalized)
}
export function findStudyUserById(userId) {
if (typeof userId !== 'string' || !userId.trim()) return undefined
return state.studyUsers.find(user => user.id === userId)
}
export function findStudyUserByUsername(username) {
return state.studyUsers.find(user => user.username === normalizeStudyUsername(username))
}
// ── Avatar ─────────────────────────────────────────────────────────────────
export function getStudyAvatarUrl(subject) {
let customAvatar = ''
let username = ''
if (subject && typeof subject === 'object') {
customAvatar = typeof subject.avatarUrl === 'string' ? subject.avatarUrl.trim() : ''
username = normalizeStudyUsername(subject.username)
} else if (typeof subject === 'string') {
username = normalizeStudyUsername(subject)
}
if (customAvatar) return customAvatar
if (!username) return ''
const hash = createHash('md5').update(username).digest('hex')
return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=96`
}
// ── Session management ─────────────────────────────────────────────────────
export function cookieFlags() {
return cookieSecureFlag()
}
export function createStudySession(userId) {
const token = randomUUID()
state.studySessions.set(token, { userId, expiresAt: Date.now() + STUDY_SESSION_TTL_MS })
return token
}
export function setStudySessionCookie(res, token) {
res.append(
'Set-Cookie',
`${STUDY_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(STUDY_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
)
}
export function clearStudySessionCookie(res) {
res.append(
'Set-Cookie',
`${STUDY_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
)
}
export function getStudyUserFromRequest(req) {
const cookies = parseCookies(req.headers.cookie)
const token = cookies[STUDY_SESSION_COOKIE]
if (!token) return null
const session = state.studySessions.get(token)
if (!session || session.expiresAt <= Date.now()) {
state.studySessions.delete(token)
return null
}
const user = state.studyUsers.find(item => item.id === session.userId)
if (!user) {
state.studySessions.delete(token)
return null
}
session.expiresAt = Date.now() + STUDY_SESSION_TTL_MS
state.studySessions.set(token, session)
return user
}
export function requireStudyAuth(req, res, next) {
const user = getStudyUserFromRequest(req)
if (!user) {
res.status(401).json({ message: 'Please sign in to save notes.' })
return
}
req.studyUser = user
next()
}
// ── TOTP pending tokens ────────────────────────────────────────────────────
export function createStudyTotpPendingToken(userId) {
const token = randomUUID()
state.studyTotpPendingTokens.set(token, { userId, expiresAt: Date.now() + STUDY_TOTP_PENDING_TTL_MS })
return token
}
export function consumeStudyTotpPendingToken(token) {
const entry = state.studyTotpPendingTokens.get(token)
if (!entry) return null
state.studyTotpPendingTokens.delete(token)
if (Date.now() > entry.expiresAt) return null
return entry.userId
}
// ── Email OTP ──────────────────────────────────────────────────────────────
export function generateEmailOtp() {
return String(Math.floor(100000 + Math.random() * 900000))
}
function hashEmailOtp(code) {
return createHash('sha256').update(String(code).trim()).digest('hex')
}
export function storeEmailOtp(userId, code) {
state.emailOtpStore.set(userId, { codeHash: hashEmailOtp(code), expiresAt: Date.now() + EMAIL_OTP_TTL_MS, attempts: 0 })
}
export function verifyEmailOtp(userId, code) {
const entry = state.emailOtpStore.get(userId)
if (!entry) return 'no-code'
if (Date.now() > entry.expiresAt) { state.emailOtpStore.delete(userId); return 'expired' }
entry.attempts += 1
if (entry.attempts > EMAIL_OTP_MAX_ATTEMPTS) { state.emailOtpStore.delete(userId); return 'too-many' }
if (hashEmailOtp(String(code).trim()) !== entry.codeHash) return 'wrong'
state.emailOtpStore.delete(userId)
return 'ok'
}
// ── Titus download tokens ──────────────────────────────────────────────────
export function createTitusDownloadToken(email) {
const token = randomUUID()
state.titusDownloadTokens.set(token, {
email,
expiresAt: Date.now() + (10 * 60 * 1000),
})
return token
}
export function consumeTitusDownloadToken(token) {
const entry = state.titusDownloadTokens.get(token)
if (!entry) return false
state.titusDownloadTokens.delete(token)
if (entry.expiresAt <= Date.now()) return false
return true
}
// ── Release date helpers ───────────────────────────────────────────────────
export function getSectionReleaseTime(section) {
if (!section || typeof section !== 'object') return Number.NaN
const candidateValues = [section.releasedAt, section.releaseDate, section.availableAt, section.publishAt]
for (const candidate of candidateValues) {
if (typeof candidate !== 'string' || !candidate.trim()) continue
const releaseTime = Date.parse(candidate)
if (Number.isFinite(releaseTime)) return releaseTime
}
return Number.NaN
}
export function getSectionReleaseDate(section) {
const releaseTime = getSectionReleaseTime(section)
if (!Number.isFinite(releaseTime)) return null
return new Date(releaseTime)
}
export function isSectionReleased(section) {
const releaseTime = getSectionReleaseTime(section)
if (!Number.isFinite(releaseTime)) return false
return releaseTime <= Date.now()
}
export function redactUnreleasedSection(section) {
if (!section || typeof section !== 'object') return section
if (isSectionReleased(section)) return section
return {
...section,
passageText: '',
commentary: '',
greekNotes: [],
studyQuestions: [],
audioEmbedUrl: '',
}
}
export function filterSiteContentByReleaseDate(siteContent) {
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return siteContent
const filteredStudies = Array.isArray(siteContent.studies)
? siteContent.studies.map(study => {
if (!study || typeof study !== 'object') return study
const sections = Array.isArray(study.sections) ? study.sections.map(redactUnreleasedSection) : []
return { ...study, sections }
})
: siteContent.studies
const filteredLegacySections = Array.isArray(siteContent.colossiansStudySections)
? siteContent.colossiansStudySections.map(redactUnreleasedSection)
: siteContent.colossiansStudySections
return {
...siteContent,
studies: filteredStudies,
colossiansStudySections: filteredLegacySections,
}
}
// ── Analytics helpers ──────────────────────────────────────────────────────
export function normalizeHitPath(pathname) {
if (!pathname || pathname === '') return '/'
if (pathname.length > 1 && pathname.endsWith('/')) {
return pathname.slice(0, -1)
}
return pathname
}
export function shouldCountHit(req) {
if (req.method !== 'GET') return false
if (req.path.startsWith('/api/')) return false
if (req.path === '/admin' || req.path.startsWith('/admin/')) return false
if (req.path === '/favicon.ico') return false
const hasFileExt = path.extname(req.path) !== ''
if (hasFileExt) return false
const accept = req.get('accept') ?? ''
return accept.includes('text/html') || accept === '*/*' || accept === ''
}
export function sanitizeUserAgent(userAgent) {
if (!userAgent || typeof userAgent !== 'string') return 'unknown'
return userAgent.trim().slice(0, 300) || 'unknown'
}
export function detectDevice(userAgent) {
if (!userAgent || typeof userAgent !== 'string') return 'unknown'
const ua = userAgent.toLowerCase()
if (/tablet|ipad|playbook|silk|(android(?!.*mobile))/.test(ua)) return 'tablet'
if (/mobile|iphone|ipod|android|blackberry|opera mini|opera mobi|iemobile|windows phone|palm|smartphone/.test(ua)) return 'mobile'
return 'desktop'
}
export function sanitizeReferrer(referrer) {
if (!referrer || typeof referrer !== 'string') return ''
try {
const parsed = new URL(referrer.trim())
return `${parsed.hostname}${parsed.pathname}`.slice(0, 200)
} catch {
return ''
}
}
export function detectBot(userAgent) {
if (!userAgent || typeof userAgent !== 'string') {
return { isBot: true, reason: 'missing-user-agent' }
}
const ua = userAgent.toLowerCase()
if (/googlebot|bingbot|yandexbot|baiduspider|slurp|duckduckbot|sluplicate|googlebot-mobile/.test(ua)) {
return { isBot: true, reason: 'search-crawler' }
}
if (/facebookexternalhit|twitterbot|linkedinbot|pinterest|whatsapp|slack|discord|telegram|reddit|mastodon/.test(ua)) {
return { isBot: true, reason: 'social-crawler' }
}
if (/headless|phantomjs|puppeteer|playwright|selenium|nightmarebot|watir|webdriver|wdio|nightmare/.test(ua)) {
return { isBot: true, reason: 'headless-browser' }
}
if (/uptimerobot|pingdom|statuspage|pagerduty|sentry|datadog|grafana|prometheus|newrelic|appdynamics/.test(ua)) {
return { isBot: true, reason: 'monitoring-tool' }
}
if (/nmap|nikto|masscan|metasploit|nessus|openvas|qualys|burpsuite|zap|acunetix|sqlmap/.test(ua)) {
return { isBot: true, reason: 'security-scanner' }
}
if (/^(curl|wget|python|java|go|node|ruby|php|perl|lua|rust)[\/-]/.test(ua)) {
return { isBot: true, reason: 'http-client' }
}
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)) {
return { isBot: true, reason: 'bot-keyword' }
}
}
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) {
const counts = {}
for (const row of list) {
const val = row?.[key] || 'Unknown'
counts[val] = (counts[val] ?? 0) + 1
}
return Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([name, hits]) => ({ name, hits }))
}
export function buildLastNDaysStats(days) {
const out = []
const today = new Date()
for (let i = days - 1; i >= 0; i -= 1) {
const d = new Date(today)
d.setDate(today.getDate() - i)
const dayKey = d.toISOString().slice(0, 10)
out.push({ day: dayKey, hits: state.hitStats.byDay[dayKey] ?? 0 })
}
return out
}
export function recordHit(pathname, isBot = false, botReason = null) {
const nowIso = new Date().toISOString()
const dayKey = nowIso.slice(0, 10)
const safePath = normalizeHitPath(pathname)
state.hitStats.totalHits += 1
state.hitStats.lastHitAt = nowIso
state.hitStats.firstHitAt = state.hitStats.firstHitAt ?? nowIso
if (isBot) {
state.hitStats.botHits += 1
state.hitStats.byPathBot[safePath] = (state.hitStats.byPathBot[safePath] ?? 0) + 1
state.hitStats.byDayBot[dayKey] = (state.hitStats.byDayBot[dayKey] ?? 0) + 1
if (botReason) {
state.hitStats.botReasons[botReason] = (state.hitStats.botReasons[botReason] ?? 0) + 1
}
} else {
state.hitStats.realHits += 1
state.hitStats.byPathReal[safePath] = (state.hitStats.byPathReal[safePath] ?? 0) + 1
state.hitStats.byDayReal[dayKey] = (state.hitStats.byDayReal[dayKey] ?? 0) + 1
}
state.hitStats.byPath[safePath] = (state.hitStats.byPath[safePath] ?? 0) + 1
state.hitStats.byDay[dayKey] = (state.hitStats.byDay[dayKey] ?? 0) + 1
}
export function pruneStatsByDays(daysRaw) {
const { VISITOR_RETENTION_DAYS_DEFAULT, EMPTY_HIT_STATS: _unused } = { VISITOR_RETENTION_DAYS_DEFAULT: 180 }
const days = Number(daysRaw)
const retentionDays = Number.isFinite(days) && days > 0 ? Math.floor(days) : 180
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000
const keepRecent = state.visitorStats.recentVisits.filter(v => {
const ts = new Date(v.at).getTime()
return Number.isFinite(ts) && ts >= cutoff
})
const allowedVisitorIds = new Set(keepRecent.map(v => v.visitorId))
const nextVisitors = {}
for (const [id, data] of Object.entries(state.visitorStats.visitors)) {
const lastSeen = new Date(data.lastSeenAt ?? 0).getTime()
if (allowedVisitorIds.has(id) || (Number.isFinite(lastSeen) && lastSeen >= cutoff)) {
nextVisitors[id] = data
}
}
const nextByDay = {}
const nextByDayReal = {}
const nextByDayBot = {}
for (const [day, count] of Object.entries(state.hitStats.byDay)) {
const ts = new Date(`${day}T00:00:00.000Z`).getTime()
if (Number.isFinite(ts) && ts >= cutoff) {
nextByDay[day] = count
nextByDayReal[day] = state.hitStats.byDayReal?.[day] ?? 0
nextByDayBot[day] = state.hitStats.byDayBot?.[day] ?? 0
}
}
state.visitorStats.recentVisits = keepRecent
state.visitorStats.visitors = nextVisitors
state.visitorStats.uniqueVisitors = Object.keys(nextVisitors).length
state.visitorStats.totalVisits = keepRecent.length
state.visitorStats.returningVisits = keepRecent.filter(v => v.returningVisitor).length
state.visitorStats.firstVisitAt = keepRecent.length > 0 ? keepRecent[keepRecent.length - 1].at : null
state.visitorStats.lastVisitAt = keepRecent.length > 0 ? keepRecent[0].at : null
state.hitStats.byDay = nextByDay
state.hitStats.byDayReal = nextByDayReal
state.hitStats.byDayBot = nextByDayBot
return {
retentionDays,
remainingVisits: state.visitorStats.totalVisits,
remainingVisitors: state.visitorStats.uniqueVisitors,
}
}
// ── 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 ───────────────────────────────────────────────
export function noteContactEmailCooldown(emailAddress) {
const normalized = String(emailAddress || '').trim().toLowerCase()
if (!normalized) return { ok: true, retryAfterMs: 0 }
const now = Date.now()
const lastAt = state.contactSubmitCooldownByEmail.get(normalized)
if (typeof lastAt === 'number' && now - lastAt < CONTACT_EMAIL_COOLDOWN_MS) {
return { ok: false, retryAfterMs: CONTACT_EMAIL_COOLDOWN_MS - (now - lastAt) }
}
state.contactSubmitCooldownByEmail.set(normalized, now)
if (state.contactSubmitCooldownByEmail.size > 8000) {
const cutoff = now - CONTACT_EMAIL_COOLDOWN_MS * 3
for (const [email, timestamp] of state.contactSubmitCooldownByEmail.entries()) {
if (timestamp < cutoff) state.contactSubmitCooldownByEmail.delete(email)
}
}
return { ok: true, retryAfterMs: 0 }
}
export function extractTagValue(tags, name) {
if (!Array.isArray(tags)) return ''
const target = String(name || '').trim().toLowerCase()
if (!target) return ''
for (const tag of tags) {
if (!tag || typeof tag !== 'object') continue
const key = typeof tag.name === 'string' ? tag.name.trim().toLowerCase() : ''
const value = typeof tag.value === 'string' ? tag.value.trim() : ''
if (key === target && value) return value
}
return ''
}
export function mapResendEventToStatus(eventType) {
const normalized = String(eventType || '').trim().toLowerCase()
if (!normalized) return 'updated'
if (normalized.includes('delivered')) return 'delivered'
if (normalized.includes('delivery_delayed') || normalized.includes('delivery delayed')) return 'delayed'
if (normalized.includes('bounce')) return 'bounced'
if (normalized.includes('complain')) return 'complained'
if (normalized.includes('click')) return 'clicked'
if (normalized.includes('open')) return 'opened'
if (normalized.includes('send')) return 'sent'
return 'updated'
}
export function extractResendMessageId(result) {
if (!result || typeof result !== 'object') return ''
if (typeof result.id === 'string' && result.id.trim()) return result.id.trim()
if (result.data && typeof result.data === 'object' && typeof result.data.id === 'string' && result.data.id.trim()) {
return result.data.id.trim()
}
return ''
}
export function normalizeMessageType(value) {
if (value === 'question' || value === 'testimony' || value === 'topic') return value
return 'general'
}
// ── Study reminder scheduler ───────────────────────────────────────────────
export async function scheduleStudyReminders(sendStudyReminderEmail) {
if (!state.cachedSiteContent) return
const now = new Date()
for (const user of state.studyUsers) {
if (user.studyRemindersEnabled !== true) continue
const email = user.username
const displayName = user.displayName || email
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
if (enrolledStudySlugs.length === 0) continue
const userSent = state.studyReminders.users[user.id] ?? {}
for (const studySlug of enrolledStudySlugs) {
const study = Array.isArray(state.cachedSiteContent.studies)
? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
: undefined
if (!study) continue
for (const section of study.sections ?? []) {
const sectionId = section.id
const releaseDate = getSectionReleaseDate(section)
if (!releaseDate) continue
// Lessons release at 1:00 AM; reminder emails go out 8 hours later, at 9:00 AM the same day.
const sendDate = new Date(releaseDate.getTime() + 8 * 60 * 60 * 1000)
if (sendDate > now) continue
const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
if (sentForStudy.includes(sectionId)) continue
const hoursSinceSend = (now.getTime() - sendDate.getTime()) / (1000 * 60 * 60)
if (hoursSinceSend > 24) continue
const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
const sectionUrl = `${base}/study/${study.slug}/${section.id}`
try {
await sendStudyReminderEmail(email, displayName, study.title, section.title, section.reference, sectionUrl)
} catch (err) {
console.error(`[study-reminders] failed to send reminder to ${email} for ${studySlug}/${sectionId}:`, err)
continue
}
userSent[studySlug] = [...sentForStudy, sectionId]
state.studyReminders.users[user.id] = userSent
// Persist after each successful send so a later crash doesn't re-send
queueStudyRemindersWrite()
}
}
}
state.studyReminders.updatedAt = new Date().toISOString()
queueStudyRemindersWrite()
}
// ── Study re-engagement scheduler ─────────────────────────────────────────
function getReengagementTiers() {
const cfg = state.cachedSiteContent ?? {}
const raw = typeof cfg.studyReengagementDays === 'string' ? cfg.studyReengagementDays : '7,14,30'
const days = raw.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n) && n > 0)
if (days.length === 0) return [{ key: 't1', days: 7 }, { key: 't2', days: 14 }, { key: 't3', days: 30 }]
return days.slice(0, 3).map((d, i) => ({ key: `t${i + 1}`, days: d }))
}
function makeUnsubUrl(userId) {
const secret = process.env.RESEND_WEBHOOK_TOKEN ?? 'siteforge'
const token = createHmac('sha256', secret).update(userId).digest('hex')
const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
return `${base}/api/study-auth/unsubscribe-reminders?uid=${encodeURIComponent(userId)}&token=${token}`
}
export async function scheduleReengagementEmails(sendStudyReengagementEmail) {
if (!state.cachedSiteContent) return
const now = new Date()
const nowMs = now.getTime()
const tiers = getReengagementTiers()
for (const user of state.studyUsers) {
if (user.studyRemindersEnabled !== true) continue
if (!user.lastLoginAt) continue
const lastLogin = new Date(user.lastLoginAt)
if (isNaN(lastLogin.getTime())) continue
const daysSinceLogin = (nowMs - lastLogin.getTime()) / (1000 * 60 * 60 * 24)
const sent = typeof user.reengagementSentAt === 'object' && user.reengagementSentAt !== null ? user.reengagementSentAt : {}
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
const studies = Array.isArray(state.cachedSiteContent.studies) ? state.cachedSiteContent.studies : []
const enrolledStudies = enrolledStudySlugs
.map(slug => studies.find(s => normalizeStudySlug(s?.slug) === slug))
.filter(Boolean)
if (enrolledStudies.length === 0) continue
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`
for (const tier of tiers) {
if (daysSinceLogin < tier.days) continue
if (sent[tier.key]) continue
const unsubUrl = makeUnsubUrl(user.id)
try {
await sendStudyReengagementEmail(user.username, user.displayName || user.username, studyTitle, studyUrl, tier.key, unsubUrl)
} catch (err) {
console.error(`[study-reengagement] failed to send ${tier.key} to ${user.username}:`, err)
continue
}
user.reengagementSentAt = { ...sent, [tier.key]: new Date().toISOString() }
queueStudyUsersWrite()
break // one tier per run so we don't spam if they've been gone >30 days
}
}
}
+4845 -139
View File
File diff suppressed because it is too large Load Diff
+11181 -263
View File
File diff suppressed because it is too large Load Diff
+2659 -264
View File
File diff suppressed because it is too large Load Diff
+129
View File
@@ -0,0 +1,129 @@
import { useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
const CONSENT_KEY = 'vbn_analytics_consent_choice'
function hasConsent(): boolean {
return localStorage.getItem(CONSENT_KEY) === 'accepted'
}
export function sendEvent(type: string, data: Record<string, unknown>): void {
if (!hasConsent()) return
fetch('/api/analytics/event', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, ...data }),
keepalive: true,
}).catch(() => {})
}
// --- Scroll depth ---
export function useScrollDepthTracking() {
const location = useLocation()
const milestones = useRef(new Set<number>())
useEffect(() => {
milestones.current = new Set()
function onScroll() {
if (!hasConsent()) return
const el = document.documentElement
const pct = Math.round((el.scrollTop / (el.scrollHeight - el.clientHeight)) * 100)
for (const mark of [25, 50, 75, 90]) {
if (pct >= mark && !milestones.current.has(mark)) {
milestones.current.add(mark)
sendEvent('scroll_depth', { path: location.pathname, depth: mark })
}
}
}
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [location.pathname])
}
// --- Time on page ---
export function useTimeOnPage() {
const location = useLocation()
const enteredAt = useRef(Date.now())
const path = useRef(location.pathname)
useEffect(() => {
enteredAt.current = Date.now()
path.current = location.pathname
function send() {
if (!hasConsent()) return
const seconds = Math.round((Date.now() - enteredAt.current) / 1000)
if (seconds < 3) return
sendEvent('time_on_page', { path: path.current, seconds })
}
const onVisibilityChange = () => { if (document.hidden) send() }
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange)
send()
}
}, [location.pathname])
}
// --- UTM capture (runs once per page load) ---
export function useUTMCapture() {
const captured = useRef(false)
useEffect(() => {
if (captured.current || !hasConsent()) return
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) return
captured.current = true
sendEvent('utm', { path: window.location.pathname, ...utm })
}, [])
}
// --- All link clicks (internal + external) ---
export function useLinkClickTracking() {
useEffect(() => {
function onClick(e: MouseEvent) {
if (!hasConsent()) return
const target = (e.target as HTMLElement).closest('a')
if (!target) return
const href = target.getAttribute('href') ?? ''
if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return
let url: string
let internal: boolean
if (href.startsWith('http') || href.startsWith('//')) {
try {
const parsed = new URL(href)
if (parsed.hostname === window.location.hostname) {
url = parsed.pathname
internal = true
} else {
url = href
internal = false
}
} catch { return }
} else if (href.startsWith('/')) {
url = href.split('?')[0]
internal = true
} else {
return
}
sendEvent('link_click', { url: url.slice(0, 500), internal })
}
document.addEventListener('click', onClick, { capture: true })
return () => document.removeEventListener('click', onClick, { capture: true })
}, [])
}
/** @deprecated Use useLinkClickTracking instead */
export function useOutboundLinkTracking() { useLinkClickTracking() }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
export type ColossiansStudySection = {
id: string
chapter: number
reference: string
title: string
audioEmbedUrl?: string
passageText: string
summary: string
commentary: string
greekNotes: string[]
studyQuestions: string[]
focusQuestion?: string
announcement?: string
checkpointPrompt?: string
checkpointQuestions?: string[]
releasedAt?: string
}
function section(
id: string,
chapter: number,
reference: string,
title: string,
audioEmbedUrl: string,
passageText: string,
summary: string,
commentary: string,
studyQuestions: string[],
greekNotes: string[] = [],
releasedAt: string | undefined = undefined,
checkpointPrompt: string | undefined = undefined,
checkpointQuestions: string[] = [],
): ColossiansStudySection {
return {
id,
chapter,
reference,
title,
audioEmbedUrl,
passageText,
summary,
commentary,
studyQuestions,
greekNotes,
announcement: undefined,
checkpointPrompt,
checkpointQuestions,
...(releasedAt ? { releasedAt } : {}),
}
}
export const DEFAULT_COLOSSIANS_STUDY_SECTIONS: ColossiansStudySection[] = [
section(
'1-1-2',
1,
'1:1-2',
'Paul\'s Greeting',
'',
'v. 1 Paul, an apostle of Christ Jesus by the will of God, and Timothy our brother,\n\n'
+ 'v. 2 To the saints in Colossae, the faithful brothers in Christ: Grace and peace to you from God our Father.',
"Paul opens with apostolic authority and warm fellowship, greeting the saints at Colossae with the twin gifts that frame every believer's life: grace and peace.",
"Paul opens with his credentials: an apostle of Christ Jesus. But the manner of the claim matters - he immediately attributes this not to his own merit but to the will of God. This is the same humility that runs beneath the letter's entire argument: everything comes from God, not from human achievement. Timothy is included not as a co-author but as a fellow laborer, identified simply as 'our brother' - the relational warmth is immediate. The recipients are addressed as 'saints' - the Greek hagios means holy ones, set apart by God - and as 'faithful brothers in Christ.' These are not terms of flattery but of theological definition: these are people who belong to God. Paul's greeting, 'grace and peace,' is more than convention. Grace (charis) is God's unmerited favor; peace (eirene) is the wholeness and well-being that flows from it. Every believer's life is framed by these two realities.",
['Why does Paul begin with grace and peace?', 'What tone does this create for the rest of the study?'],
[
'Apostle (apostolos): one commissioned directly by Jesus Christ, invested with authority to speak on His behalf.',
'Will (thelema): desire or purpose; Paul was not self-appointed but called by God\'s initiative.',
'Brother (adelphos): a fellow believer; the same spiritual-family language Paul uses throughout his letters.',
'Saints (hagios): God\'s holy people set apart as belonging to Him, not holy by their own merit.',
"Grace (charis): God's unmerited favor that initiates and sustains the believer's life.",
'Peace (eirene): wholeness and well-being that flows from reconciliation with God.',
],
'2026-11-09T00:00:00Z',
'What do you want to remember from this greeting as you begin the lesson?',
['Why does Paul begin with grace and peace?', 'What tone does this create for the rest of the study?'],
),
section('1-3-8', 1, '1:3-8', 'Thanksgiving and Report', '', '', 'Paul thanks God for faith, hope, and love, and reports that the gospel is bearing fruit.', 'The gospel is shown to be active and alive. Paul points to evidence of real spiritual growth, not merely religious activity.', ['What signs of gospel growth does Paul mention?', 'How do faith, hope, and love work together here?'], [], '2026-11-23T00:00:00Z'),
section('1-9-14', 1, '1:9-14', 'Paul\'s Prayer', '', '', 'Paul prays for spiritual wisdom, endurance, and a life worthy of the Lord.', 'This prayer shows that knowledge and fruitfulness belong together. Paul wants the believers to know God\'s will and walk in a way that reflects it.', ['What does Paul ask God to produce?', 'How does this prayer shape the goals of the study?'], [], '2026-12-07T00:00:00Z'),
section('1-15-20', 1, '1:15-20', 'The Supremacy of Christ', '', '', 'Christ is supreme over creation, the church, and reconciliation.', 'This is the center of Colossians. Everything else in the letter depends on who Christ is and what He has done.', ['What stands out most about Christ\'s supremacy?', 'Why is this passage central to the study?'], [], '2026-12-21T00:00:00Z'),
section('1-21-23', 1, '1:21-23', 'Reconciliation Applied', '', '', 'Paul explains how Christ\'s work changes alienation into steadfast faith.', 'The gospel moves from doctrine to real life. Paul shows what reconciliation looks like for people who have been brought near to God.', ['How does Paul describe the believer\'s former condition?', 'What does continuing in the faith look like?'], [], '2027-01-04T00:00:00Z'),
section('1-24-29', 1, '1:24-29', 'Paul\'s Ministry and the Revealed Mystery', '', '', 'Paul describes his labor to proclaim Christ and present believers mature in Him.', 'Paul\'s ministry is costly and purposeful. He labors so the church will grow into maturity, not just receive information.', ['What is the goal of Paul\'s ministry?', 'Why is maturity such an important theme here?'], [], '2027-01-18T00:00:00Z'),
section('2-1-5', 2, '2:1-5', 'Paul\'s Pastoral Concern', '', '', 'Paul reveals his struggle for the believers and his desire for encouragement and unity.', 'This passage shows the heart behind the letter. Paul is deeply invested in the spiritual stability of the church.', ['What does Paul want the believers to experience?', 'Why does he emphasize encouragement and unity?'], [], '2027-02-01T00:00:00Z'),
section('2-6-8', 2, '2:6-8', 'Walk in Christ', '', '', 'Paul calls believers to continue in Christ and resist deceptive teaching.', 'The Christian life begins in Christ and must continue in Christ. Paul warns that subtle deception can slowly pull believers off course.', ['What does it mean to continue in Christ?', 'What kinds of deception should believers watch for?'], [], '2027-02-15T00:00:00Z'),
section('2-9-10', 2, '2:9-10', 'Supremacy and Sufficiency', '', '', 'The fullness of deity dwells in Christ, and believers are made complete in Him.', 'This section corrects the idea that anything must be added to Christ. He is fully sufficient for the believer\'s life and growth.', ['Why is fullness in Christ such an important correction?', 'How does this guard against spiritual insecurity?'], [], '2027-03-01T00:00:00Z'),
section('2-11-15', 2, '2:11-15', 'What Christ Has Done', '', '', 'Paul explains the believer\'s union with Christ in His death, resurrection, and victory.', 'The work of Christ is presented as decisive and complete. This becomes the foundation for all the commands that follow.', ['What changes because of Christ\'s work here?', 'How does victory over the powers encourage believers today?'], [], '2027-03-15T00:00:00Z'),
section('2-16-17', 2, '2:16-17', 'Freedom from Legalistic Judgment', '', '', 'Paul warns against letting others judge believers over shadows when Christ is the substance.', 'Outward practices can never replace Christ. Paul reminds the church that the real thing has come in Jesus.', ['What do shadows and substance mean here?', 'Where do believers still feel pressure from external judgment?'], [], '2027-03-29T00:00:00Z'),
section('2-18-19', 2, '2:18-19', 'Warning Against False Mysticism', '', '', 'Paul warns against prideful spirituality that disconnects believers from Christ the head.', 'Anything that pulls attention away from Christ and His body is spiritually dangerous. True growth stays connected to the head.', ['How does Paul describe false spirituality?', 'Why is connection to Christ essential?'], [], '2027-04-12T00:00:00Z'),
section('2-20-23', 2, '2:20-23', 'The Emptiness of Human Regulations', '', '', 'Human rules cannot produce true transformation.', 'External restrictions may look wise, but they cannot change the heart. Paul points the church back to union with Christ.', ['Why do human regulations fail to transform the heart?', 'What does real spiritual change require?'], [], '2027-04-26T00:00:00Z'),
section('3-1-4', 3, '3:1-4', 'Seek the Things Above', '', '', 'Paul calls believers to set their hearts and minds on Christ.', 'Identity in Christ leads to a new direction for the mind and heart. The believer\'s life is hidden with Christ, so priorities shift upward.', ['What does it mean to seek the things above?', 'How does hidden life in Christ shape daily priorities?'], [], '2027-05-10T00:00:00Z'),
section('3-5-9', 3, '3:5-9', 'Put to Death the Old Life', '', '', 'Paul calls believers to put away the practices of the old life.', 'Christian growth includes real moral change. Paul\'s language is strong because the old life cannot be carried into the new one.', ['Which old patterns does Paul name?', 'Why is decisive change necessary?'], [], '2027-05-24T00:00:00Z'),
section('3-10-11', 3, '3:10-11', 'Put On the New Self', '', '', 'Believers are renewed in Christ and formed into one new people.', 'Renewal in Christ changes both personal identity and community life. The new self is not merely private; it is shared by the body of Christ.', ['What does renewal in Christ produce?', 'How does this reshape how believers see one another?'], [], '2027-06-07T00:00:00Z'),
section('3-12-14', 3, '3:12-14', 'Clothe Yourselves with Christlike Virtues', '', '', 'Paul lists the virtues believers should wear as Christ\'s character becomes visible in them.', 'The image of clothing makes virtue practical and visible. Paul wants Christ\'s character to define the way believers live together.', ['Which virtues are most needed in your context?', 'How does love bind these virtues together?'], [], '2027-06-21T00:00:00Z'),
section('3-15-17', 3, '3:15-17', 'The Rule of Christ\'s Peace and Word', '', '', 'Paul calls the church to let Christ\'s peace rule and His word dwell richly among them.', 'Peace, gratitude, and the word of Christ shape a healthy Christian community. These verses connect worship, teaching, and daily life.', ['What does it look like for Christ\'s peace to rule?', 'How can the word of Christ dwell richly in a church family?'], [], '2027-07-05T00:00:00Z'),
section('3-18-21', 3, '3:18-21', 'Household Code — Relationships', '', '', 'Paul applies the new life in Christ to family relationships.', 'The gospel reaches into ordinary relationships. Christ changes how homes are ordered and how people treat one another.', ['How do these instructions reflect Christ\'s lordship?', 'Which relationship is most challenging to apply today?'], [], '2027-07-19T00:00:00Z'),
section('3-22-25', 3, '3:22-25', 'Household Code — Work as unto the Lord', '', '', 'Paul instructs believers to serve faithfully and wholeheartedly.', 'Work becomes worship when it is done for Christ. Paul teaches integrity, diligence, and accountability before a heavenly Master.', ['How does serving the Lord reshape everyday work?', 'What does Paul say about fairness and accountability?'], [], '2027-08-02T00:00:00Z'),
section('4-1', 4, '4:1', 'Masters — Fairness Before a Heavenly Master', '', '', 'Paul reminds masters to act justly because they too answer to the Lord.', 'The gospel confronts authority as well as submission. All authority is accountable to Christ.', ['Why does Paul remind masters of their heavenly Master?', 'What does fairness look like in this context?'], [], '2027-08-16T00:00:00Z'),
section('4-2-6', 4, '4:2-6', 'Devoted to Prayer and Wise Witness', '', '', 'Paul closes with a call to steadfast prayer and wise witness toward outsiders.', 'Prayer and witness belong together. Paul shows that spiritual alertness and everyday speech are both part of faithful ministry.', ['How are prayer and witness connected here?', 'What does gracious speech look like in real life?'], [], '2027-08-30T00:00:00Z'),
section('4-7-9', 4, '4:7-9', 'Tychicus and Onesimus', '', '', 'Paul sends trusted messengers to encourage the church and bring news from the ministry field.', 'These greetings reveal the relational side of ministry. The letter is carried by real people who serve the church with loyalty and care.', ['Why do these messengers matter to the letter?', 'What does this tell us about trusted coworkers?'], [], '2027-09-13T00:00:00Z'),
section('4-10-14', 4, '4:10-14', 'Greetings from Paul\'s Co-workers', '', '', 'Paul names the coworkers who are with him and commends their ministry support.', 'The closing greetings show the breadth of the gospel network. Ministry is shared, supported, and strengthened by faithful co-workers.', ['What stands out about Paul\'s coworkers?', 'How does shared ministry strengthen the church?'], [], '2027-09-27T00:00:00Z'),
section('4-15-18', 4, '4:15-18', 'Final Instructions and Closing', '', '', 'Paul offers final greetings, instructions, and a closing reminder to complete the ministry entrusted to them.', 'The letter ends with community, responsibility, and grace. Paul\'s final words keep the church focused on the mission it has received.', ['What final responsibility does Paul place on the church?', 'How does the closing reinforce the letter\'s themes?'], [], '2027-10-11T00:00:00Z'),
]
+51
View File
@@ -0,0 +1,51 @@
import type { ReactNode } from 'react'
interface AdminCollapsibleCardProps {
title: string
subtitle?: string
hint?: string
previewUrl?: string
children: ReactNode
open?: boolean
className?: string
}
/**
* Reusable collapsible card for admin forms.
* Replaces the repeated <details>/<summary> pattern throughout AdminPage.
*/
export function AdminCollapsibleCard({
title,
subtitle,
hint = 'Expand to edit',
previewUrl,
children,
open = false,
className = '',
}: AdminCollapsibleCardProps) {
return (
<details className={`admin-collapsible-card ${className}`.trim()} open={open}>
<summary className="admin-collapsible-summary">
<div>
<strong>{title}</strong>
{subtitle && <p>{subtitle}</p>}
</div>
<div className="admin-collapsible-actions">
{previewUrl && (
<a
href={previewUrl}
target="_blank"
rel="noopener noreferrer"
className="btn-admin-secondary"
onClick={e => e.stopPropagation()}
>
Preview
</a>
)}
<span className="admin-collapsible-hint">{hint}</span>
</div>
</summary>
<div className="admin-collapsible-body">{children}</div>
</details>
)
}
+893
View File
@@ -0,0 +1,893 @@
import { useState } from 'react'
import { Line, Bar, Pie, Doughnut } from 'react-chartjs-2'
import GeoMaps from './GeoMaps'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend,
ArcElement,
} from 'chart.js'
import type { AdminStats } from '../AdminPage'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend,
ArcElement,
)
interface Props {
stats: AdminStats | null
statsStatus: 'loading' | 'error' | 'ready'
statsRange?: '7d' | '30d' | '90d'
onRangeChange?: (range: '7d' | '30d' | '90d') => 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
formatDate: (date: string | null) => string
maskIp: (ip: string) => string
maintenanceMsg: string | null
selectedBackup: string
backupFiles: Array<{ filename: string; sizeBytes: number; createdAt: string | null; reason: string; adminUpdatedAt: string | null; totalHits: number; totalVisits: number }>
selectedBackupPreview: { filename: string; sizeBytes: number; createdAt: string | null; reason: string; adminUpdatedAt: string | null; totalHits: number; totalVisits: number } | null
onBackupSelect: (filename: string) => void
onRestore: () => void
onExport: () => void
onBackupNow: () => void
onDownloadFullBackup: () => void
onImportFullBackup: (file: File) => void
fullBackupBusy: boolean
onPrune: () => void
onClear: () => void
onPurgeCache: () => void
onDeployHook: () => void
onRefreshStatus: () => void
}
export function AnalyticsPanel({
stats,
statsStatus,
statsRange = '30d',
onRangeChange,
opsStatus,
formatDate,
maskIp,
maintenanceMsg,
selectedBackup,
backupFiles,
selectedBackupPreview,
onBackupSelect,
onRestore,
onExport,
onBackupNow,
onDownloadFullBackup,
onImportFullBackup,
fullBackupBusy,
onPrune,
onClear,
onPurgeCache,
onDeployHook,
onRefreshStatus,
}: Props) {
const [panelTab, setPanelTab] = useState<'traffic' | 'engagement' | 'system'>('traffic')
const [chartTab, setChartTab] = useState<'overview' | 'breakdown' | 'geographic' | 'referrers'>('overview')
const [expandedVisitor, setExpandedVisitor] = useState<string | null>(null)
const [fullBackupFile, setFullBackupFile] = useState<File | null>(null)
if (statsStatus === 'loading') return <p className="admin-stats-note">Loading analytics</p>
if (statsStatus === 'error') return <p className="admin-stats-note">Failed to load analytics.</p>
if (!stats) return null
// Normalize fields that may be missing from older data
const realHits = stats.realHits ?? 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 last7DaysBot = stats.last7DaysBot ?? stats.last7Days.map((d: { day: string; hits: number }) => ({ day: d.day, hits: 0 }))
const last30DaysRealTotal = stats.last30DaysRealTotal ?? 0
const last30DaysBotTotal = stats.last30DaysBotTotal ?? 0
const topPathsReal = stats.topPathsReal ?? stats.topPaths
const topPathsBot = stats.topPathsBot ?? []
const botReasons = stats.botReasons ?? []
// Chart data - Daily trend
const dailyChartData = {
labels: stats.last7Days.map((d: { day: string; hits: number }) => d.day),
datasets: [
{
label: 'Real Visitors',
data: last7DaysReal.map((d: { day: string; hits: number }) => d.hits),
borderColor: '#c9a84c',
backgroundColor: 'rgba(201, 168, 76, 0.1)',
tension: 0.4,
fill: true,
},
{
label: 'Bot Traffic',
data: last7DaysBot.map((d: { day: string; hits: number }) => d.hits),
borderColor: '#666',
backgroundColor: 'rgba(102, 102, 102, 0.1)',
tension: 0.4,
fill: true,
},
],
}
// 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)
const trafficRatioData = {
labels: ['Real Visitors', 'Bot Traffic'],
datasets: [{
data: [realHits, botHits],
backgroundColor: ['#c9a84c', '#999'],
borderColor: '#1a1a15',
borderWidth: 2,
}],
}
// Top paths comparison
const topPathsLabels = Array.from({ length: Math.max(topPathsReal.length, topPathsBot.length) }, (_, i: number) => {
const realPath = topPathsReal[i]?.path || ''
const botPath = topPathsBot[i]?.path || ''
return realPath || botPath || `Path ${i + 1}`
}).slice(0, 8)
const topPathsChartData = {
labels: topPathsLabels,
datasets: [
{
label: 'Real Hits',
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)
const deviceBreakdown = stats.visitors.deviceBreakdown ?? { mobile: 0, desktop: 0, tablet: 0, unknown: 0 }
const deviceChartData = {
labels: ['Desktop', 'Mobile', 'Tablet', 'Unknown'],
datasets: [{
data: [deviceBreakdown.desktop, deviceBreakdown.mobile, deviceBreakdown.tablet, deviceBreakdown.unknown],
backgroundColor: ['#c9a84c', '#a0853d', '#6d5a2e', '#444'],
borderColor: '#1a1a15',
borderWidth: 2,
}],
}
const chartOptions = {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
labels: {
color: '#f0ead8',
font: { size: 12 },
},
},
tooltip: {
backgroundColor: '#2a2518',
titleColor: '#f0ead8',
bodyColor: '#f0ead8',
borderColor: '#c9a84c',
borderWidth: 1,
},
},
scales: {
y: {
beginAtZero: true,
ticks: { color: '#7a7060' },
grid: { color: '#2a2518' },
},
x: {
ticks: { color: '#7a7060' },
grid: { color: '#2a2518' },
},
},
}
const rangeTotalHits = (stats as AdminStats & { rangeTotalHits?: number }).rangeTotalHits ?? totalHits
const rangeLabel = (stats as AdminStats & { rangeLabel?: string }).rangeLabel ?? statsRange
return (
<section className="admin-panel-section" aria-label="Analytics">
<div className="admin-panel-head">
<h2>Analytics Dashboard</h2>
<p>Real-time insights into site traffic, bot detection, visitor behavior, and data management.</p>
</div>
{/* Top-level panel tabs */}
<div className="admin-tabs admin-tabs--charts" style={{ marginBottom: '1.5rem' }}>
<button type="button" className={`admin-tab${panelTab === 'traffic' ? ' admin-tab--active' : ''}`} onClick={() => setPanelTab('traffic')}>Traffic</button>
<button type="button" className={`admin-tab${panelTab === 'engagement' ? ' admin-tab--active' : ''}`} onClick={() => setPanelTab('engagement')}>Engagement</button>
<button type="button" className={`admin-tab${panelTab === 'system' ? ' admin-tab--active' : ''}`} onClick={() => setPanelTab('system')}>System</button>
</div>
{panelTab === 'traffic' && (<>
{/* Time-range tabs */}
{onRangeChange && (
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
{(['7d', '30d', '90d'] as const).map(r => (
<button
key={r}
type="button"
onClick={() => onRangeChange(r)}
style={{
padding: '0.35rem 1rem',
borderRadius: '20px',
border: `1px solid ${statsRange === r ? '#c9a84c' : '#3a3320'}`,
background: statsRange === r ? 'rgba(201,168,76,0.15)' : 'transparent',
color: statsRange === r ? '#c9a84c' : '#7a7060',
cursor: 'pointer',
fontSize: '0.85rem',
fontWeight: statsRange === r ? 600 : 400,
transition: 'all 0.15s',
}}
>
{r === '7d' ? 'Last 7 days' : r === '30d' ? 'Last 30 days' : 'Last 90 days'}
</button>
))}
<span style={{ color: '#5a5440', fontSize: '0.8rem', alignSelf: 'center', marginLeft: '0.25rem' }}>
Showing: {rangeLabel}
</span>
</div>
)}
{/* KPI Grid */}
<div className="admin-stats-grid">
<article>
<h3>Total Hits</h3>
<p>{rangeTotalHits.toLocaleString()}</p>
<small>{rangeLabel} all requests</small>
</article>
<article>
<h3>Real Traffic</h3>
<p>{realHits.toLocaleString()}</p>
<small>{rangeTotalHits > 0 ? ((realHits / rangeTotalHits) * 100).toFixed(1) : '0.0'}% of total</small>
</article>
<article>
<h3>Bot Traffic</h3>
<p>{botHits.toLocaleString()}</p>
<small>{rangeTotalHits > 0 ? ((botHits / rangeTotalHits) * 100).toFixed(1) : '0.0'}% of total</small>
</article>
<article>
<h3>Last 30 Days</h3>
<p>{last30DaysRealTotal.toLocaleString()}</p>
<small>Real hits {last30DaysBotTotal.toLocaleString()} bots</small>
</article>
<article>
<h3>Unique Visitors</h3>
<p>{stats.visitors.uniqueVisitors.toLocaleString()}</p>
<small>Tracked with consent</small>
</article>
<article>
<h3>Returning Rate</h3>
<p>{stats.visitors.totalVisits > 0 ? `${Math.round((stats.visitors.returningVisits / stats.visitors.totalVisits) * 100)}%` : '0%'}</p>
<small>{stats.visitors.returningVisits.toLocaleString()} return visits</small>
</article>
</div>
{/* Chart Tabs */}
<div className="admin-tabs admin-tabs--charts">
<button type="button" className={`admin-tab${chartTab === 'overview' ? ' admin-tab--active' : ''}`} onClick={() => setChartTab('overview')}>Overview</button>
<button type="button" className={`admin-tab${chartTab === 'breakdown' ? ' admin-tab--active' : ''}`} onClick={() => setChartTab('breakdown')}>Bot &amp; Devices</button>
<button type="button" className={`admin-tab${chartTab === 'geographic' ? ' admin-tab--active' : ''}`} onClick={() => setChartTab('geographic')}>Geographic</button>
<button type="button" className={`admin-tab${chartTab === 'referrers' ? ' admin-tab--active' : ''}`} onClick={() => setChartTab('referrers')}>Referrers</button>
</div>
{/* Overview Charts */}
{chartTab === 'overview' && (
<>
<div className="admin-analytics-grid">
<div className="admin-analytics-card">
<h3 className="admin-analytics-card-title">7-Day Trend (Real vs Bot)</h3>
<Line data={dailyChartData} 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 className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
<div className="admin-analytics-card admin-analytics-card--wide">
<h3 className="admin-analytics-card-title">30-Day Real Visitor Trend</h3>
<Line data={thirtyDayChartData} options={chartOptions} />
</div>
</div>
</>
)}
{/* Bot & Device Charts */}
{chartTab === 'breakdown' && (
<>
<div className="admin-analytics-grid">
<div className="admin-analytics-card">
<h3 className="admin-analytics-card-title">Device Breakdown</h3>
<Doughnut data={deviceChartData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
</div>
<div className="admin-analytics-card">
<h3 className="admin-analytics-card-title">Device Counts</h3>
<ul className="admin-bot-reason-list">
<li><span>Desktop</span><strong>{deviceBreakdown.desktop.toLocaleString()}</strong></li>
<li><span>Mobile</span><strong>{deviceBreakdown.mobile.toLocaleString()}</strong></li>
<li><span>Tablet</span><strong>{deviceBreakdown.tablet.toLocaleString()}</strong></li>
{deviceBreakdown.unknown > 0 && <li><span>Unknown</span><strong>{deviceBreakdown.unknown.toLocaleString()}</strong></li>}
</ul>
</div>
</div>
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
<div className="admin-analytics-card">
<h3 className="admin-analytics-card-title">Bot Sources</h3>
{botReasons.length === 0 ? (
<p className="admin-stats-note">No bot traffic detected.</p>
) : (
<Pie data={botPieData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
)}
</div>
<div className="admin-analytics-card">
<h3 className="admin-analytics-card-title">Bot Detection Details</h3>
{botReasons.length === 0 ? (
<p className="admin-stats-note">No bots detected yet.</p>
) : (
<ul className="admin-bot-reason-list">
{botReasons.map((reason: { reason: string; count: number }) => (
<li key={reason.reason} className="admin-bot-reason-item">
<span>{reason.reason.replace(/-/g, ' ')}</span>
<strong>{reason.count.toLocaleString()}</strong>
</li>
))}
</ul>
)}
</div>
</div>
</>
)}
{/* Geographic Charts */}
{chartTab === 'geographic' && (
<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
topCountries={stats.visitors.topCountries}
topStates={stats.visitors.topStates}
/>
</div>
)}
{/* Referrers Tab */}
{chartTab === 'referrers' && (
<div className="admin-analytics-grid">
<div className="admin-analytics-card admin-analytics-card--wide">
<h3 className="admin-analytics-card-title">Top Traffic Sources</h3>
{(stats.visitors.topReferrers ?? []).length === 0 ? (
<p className="admin-stats-note">No referrer data yet. Referrers are captured on SPA navigations after consent.</p>
) : (
<ul className="admin-bot-reason-list">
{(stats.visitors.topReferrers ?? []).map((item: { referrer: string; count: number }) => (
<li key={item.referrer}>
<span>{item.referrer}</span>
<strong>{item.count.toLocaleString()}</strong>
</li>
))}
</ul>
)}
</div>
</div>
)}
{/* Recent Visitors Table */}
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Visitor Details</h2>
<p>Real-time tracking of visitor activity, geography, and returning behavior.</p>
</div>
<p className="admin-privacy-note">Privacy: visitor analytics only run after cookie consent. IPs below are masked.</p>
<div className="admin-stats-grid admin-stats-grid--visitor">
<article><h3>Total Visits</h3><p>{stats.visitors.totalVisits.toLocaleString()}</p></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>First Visit</h3><p>{formatDate(stats.visitors.firstVisitAt)}</p></article>
</div>
<div className="admin-stats-lists">
<div>
<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">
<h3>Recent Visitor Log</h3>
{stats.visitors.recentVisits.length === 0 ? (
<p className="admin-stats-note">No visitor records yet.</p>
) : (
<div className="admin-visitor-cards">
{stats.visitors.recentVisits.map(row => {
const rowKey = `${row.visitorId}-${row.at}`
const isExpanded = expandedVisitor === rowKey
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'
return (
<div key={rowKey} className={`admin-visitor-card${isExpanded ? ' admin-visitor-card--expanded' : ''}`}>
<button
className="admin-visitor-card__main"
onClick={() => setExpandedVisitor(isExpanded ? null : rowKey)}
aria-expanded={isExpanded}
>
<div className="admin-visitor-card__left">
<span className="admin-visitor-card__name">
{row.studyUser ? (row.studyUser.displayName || row.studyUser.username) : <code className="admin-visitor-ip">{maskIp(row.ip)}</code>}
</span>
<span className="admin-visitor-card__path">{row.path}</span>
</div>
<div className="admin-visitor-card__right">
<span className="admin-visitor-card__meta">{location}</span>
<span className="admin-visitor-card__meta">{row.device || 'unknown'} · visit #{row.visitCount}</span>
<div className="admin-visitor-card__tags">
<span className={`admin-visitor-tag admin-visitor-tag--${row.returningVisitor ? 'returning' : 'new'}`}>
{row.returningVisitor ? 'Returning' : 'New'}
</span>
{row.studyUser && <span className="admin-visitor-tag admin-visitor-tag--user">Logged in</span>}
<span className="admin-visitor-card__time">{formatDate(row.at)}</span>
</div>
</div>
<span className="admin-visitor-card__chevron">{isExpanded ? '▲' : '▼'}</span>
</button>
{isExpanded && (
<div className="admin-visitor-card__detail">
<div className="admin-visitor-card__detail-meta">
<span><strong>IP</strong> {maskIp(row.ip)}</span>
{row.studyUser && <span><strong>Username</strong> {row.studyUser.username}</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>
</div>
{history.length > 0 ? (
<>
<p className="admin-visitor-history-label">Page history ({history.length} pages)</p>
<ol className="admin-visitor-history-list">
{[...history].reverse().map((entry, i) => (
<li key={i} className="admin-visitor-history-entry">
<span className="admin-visitor-history-time">{formatDate(entry.at)}</span>
<span className="admin-visitor-history-path">{entry.path}</span>
{entry.referrer && <span className="admin-visitor-history-ref">from {entry.referrer}</span>}
</li>
))}
</ol>
</>
) : (
<p style={{ color: '#8a7f5a', fontSize: '0.82rem', margin: 0 }}>No page history yet recorded after consent is given.</p>
)}
</div>
)}
</div>
)
})}
</div>
)}
</div>
</>)}
{panelTab === 'engagement' && (<>
{/* Episode Plays */}
{stats.episodePlays && stats.episodePlays.length > 0 && (
<>
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Episode Plays</h2>
<p>Tracked each time a visitor hits play on the audio player. One count per player mount.</p>
</div>
<div className="admin-stats-chart-wrap" style={{ marginBottom: '1.5rem' }}>
<Bar
data={{
labels: stats.episodePlays.slice(0, 12).map((ep: { title: string; total: number }) => ep.title.length > 40 ? ep.title.slice(0, 40) + '…' : ep.title),
datasets: [{
label: 'Total Plays',
data: stats.episodePlays.slice(0, 12).map((ep: { title: string; total: number }) => ep.total),
backgroundColor: '#c9a84c',
borderColor: '#8a6e28',
borderWidth: 1,
borderRadius: 4,
}],
}}
options={{
indexAxis: 'y' as const,
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: (ctx) => ` ${ctx.parsed.x} plays` } },
},
scales: {
x: { ticks: { color: '#b0a48c', font: { size: 11 } }, grid: { color: 'rgba(42,37,24,0.6)' } },
y: { ticks: { color: '#f0ead8', font: { size: 11 } }, grid: { display: false } },
},
}}
style={{ height: `${Math.max(180, stats.episodePlays.slice(0, 12).length * 36)}px` }}
/>
</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>
</>
)}
{/* Engagement */}
{stats.engagement && (
<>
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Engagement</h2>
<p>Scroll depth, time on page, audio, and interaction data from consenting visitors.</p>
</div>
{/* Time on page */}
{stats.engagement.timeOnPage?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Avg. Time on Page</h3>
<div className="admin-stats-table-wrap">
<table className="admin-stats-table">
<thead><tr><th>Page</th><th>Avg Time</th><th>Sessions</th></tr></thead>
<tbody>
{stats.engagement.timeOnPage.slice(0, 15).map((row: { path: string; avgSeconds: number; count: number }) => (
<tr key={row.path}>
<td>{row.path}</td>
<td>{row.avgSeconds >= 60 ? `${Math.floor(row.avgSeconds / 60)}m ${row.avgSeconds % 60}s` : `${row.avgSeconds}s`}</td>
<td>{row.count.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Scroll depth */}
{stats.engagement.scrollDepth?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Scroll Depth</h3>
<div className="admin-stats-table-wrap">
<table className="admin-stats-table">
<thead><tr><th>Page</th><th>25%</th><th>50%</th><th>75%</th><th>90%</th></tr></thead>
<tbody>
{stats.engagement.scrollDepth.slice(0, 15).map((row: { path: string; 25?: number; 50?: number; 75?: number; 90?: number }) => (
<tr key={row.path}>
<td>{row.path}</td>
<td>{(row[25] ?? 0).toLocaleString()}</td>
<td>{(row[50] ?? 0).toLocaleString()}</td>
<td>{(row[75] ?? 0).toLocaleString()}</td>
<td>{(row[90] ?? 0).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Audio events */}
{stats.engagement.audioEvents?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Audio Engagement</h3>
<div className="admin-stats-table-wrap">
<table className="admin-stats-table">
<thead><tr><th>Episode</th><th>Completions</th><th>Pauses</th><th>Total Listen</th></tr></thead>
<tbody>
{stats.engagement.audioEvents.map((row: { title: string; pauses: number; completions: number; totalListenSeconds: number }) => {
const hrs = Math.floor(row.totalListenSeconds / 3600)
const mins = Math.floor((row.totalListenSeconds % 3600) / 60)
const listenStr = hrs > 0 ? `${hrs}h ${mins}m` : `${mins}m`
return (
<tr key={row.title}>
<td style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{row.title}</td>
<td>{row.completions.toLocaleString()}</td>
<td>{row.pauses.toLocaleString()}</td>
<td>{listenStr}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
{/* Search queries */}
{stats.engagement.topSearchQueries?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Top Search Queries</h3>
<div className="admin-stats-table-wrap">
<table className="admin-stats-table">
<thead><tr><th>Query</th><th>Searches</th></tr></thead>
<tbody>
{stats.engagement.topSearchQueries.map((row: { query: string; count: number }) => (
<tr key={row.query}><td>{row.query}</td><td>{row.count.toLocaleString()}</td></tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* All link clicks */}
{stats.engagement.topLinkClicks?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Link Clicks</h3>
<div className="admin-stats-table-wrap">
<table className="admin-stats-table">
<thead><tr><th>Destination</th><th>Type</th><th>Clicks</th></tr></thead>
<tbody>
{stats.engagement.topLinkClicks.map((row: { url: string; count: number; internal: boolean }) => (
<tr key={row.url}>
<td style={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{row.internal
? <span style={{ color: '#c9a84c' }}>{row.url}</span>
: <a href={row.url} target="_blank" rel="noreferrer" style={{ color: '#c9a84c' }}>{row.url}</a>}
</td>
<td style={{ color: row.internal ? '#6fcf97' : '#9b9b9b', fontSize: '0.8rem' }}>
{row.internal ? 'internal' : 'external'}
</td>
<td>{row.count.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Outbound clicks */}
{stats.engagement.topOutboundClicks?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Outbound Link Clicks</h3>
<div className="admin-stats-table-wrap">
<table className="admin-stats-table">
<thead><tr><th>URL</th><th>Clicks</th></tr></thead>
<tbody>
{stats.engagement.topOutboundClicks.map((row: { url: string; count: number }) => (
<tr key={row.url}>
<td style={{ maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<a href={row.url} target="_blank" rel="noreferrer" style={{ color: '#c9a84c' }}>{row.url}</a>
</td>
<td>{row.count.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* UTM sources */}
{stats.engagement.topUTMSources?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>UTM Sources</h3>
<div className="admin-stats-table-wrap">
<table className="admin-stats-table">
<thead><tr><th>Source</th><th>Visits</th></tr></thead>
<tbody>
{stats.engagement.topUTMSources.map((row: { source: string; count: number }) => (
<tr key={row.source}><td>{row.source}</td><td>{row.count.toLocaleString()}</td></tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* 404s */}
{stats.engagement.top404s?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>404 Not Found</h3>
<div className="admin-stats-table-wrap">
<table className="admin-stats-table">
<thead><tr><th>Path</th><th>Hits</th></tr></thead>
<tbody>
{stats.engagement.top404s.map((row: { path: string; count: number }) => (
<tr key={row.path}><td>{row.path}</td><td>{row.count.toLocaleString()}</td></tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
{/* Contact Summary */}
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Contact Summary</h2>
<p>Submission totals from the contact form.</p>
</div>
<div className="admin-stats-grid">
<article><h3>Total Contact Messages</h3><p>{stats.contactTotals.totalSubmissions.toLocaleString()}</p></article>
<article><h3>Total Bible Questions</h3><p>{stats.contactTotals.totalQuestions.toLocaleString()}</p></article>
</div>
{/* Study Enrollment Funnel */}
{stats.studyEnrollment?.funnel && (
<>
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Study Enrollment Funnel</h2>
<p>How many users progress from signup first visit first section completed.</p>
</div>
<div className="admin-funnel">
{(() => {
const { signups, firstVisit, firstCompletion } = stats.studyEnrollment.funnel!
const steps = [
{ label: 'Signed Up', count: signups, pct: 100 },
{ label: 'Visited a Study', count: firstVisit, pct: signups > 0 ? Math.round((firstVisit / signups) * 100) : 0 },
{ label: 'Completed a Section', count: firstCompletion, pct: signups > 0 ? Math.round((firstCompletion / signups) * 100) : 0 },
]
return steps.map((step, i) => (
<div key={i} className="admin-funnel-step">
<div className="admin-funnel-bar-wrap">
<div className="admin-funnel-bar" style={{ width: `${step.pct}%` }} />
</div>
<div className="admin-funnel-label">
<span className="admin-funnel-step-name">{step.label}</span>
<span className="admin-funnel-count">{step.count.toLocaleString()}</span>
<span className="admin-funnel-pct">{step.pct}%</span>
</div>
</div>
))
})()}
</div>
</>
)}
</>)}
{panelTab === 'system' && (<>
{/* Deployment & Cache Status */}
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Deployment &amp; Cache Status</h2>
<p>Current deployment metadata and cache purge health for the live app.</p>
</div>
<div className="admin-stats-grid">
<article><h3>Build Commit</h3><p>{opsStatus?.buildCommit ?? 'Not available'}</p></article>
<article><h3>Build Number</h3><p>{opsStatus?.buildNumber ?? 'Not available'}</p></article>
<article><h3>Deployed At</h3><p>{formatDate(opsStatus?.deployedAt ?? 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>
{/* Data Management */}
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Data Management</h2>
<p>Export, backup, or retain only recent analytics data.</p>
</div>
<div className="admin-stats-grid">
<article><h3>Hit Stats Write</h3><p>{stats.writeStatus.hitStats.ok ? 'Healthy' : 'Error'}</p><p>{formatDate(stats.writeStatus.hitStats.at)}</p></article>
<article><h3>Visitor Stats Write</h3><p>{stats.writeStatus.visitorStats.ok ? 'Healthy' : 'Error'}</p><p>{formatDate(stats.writeStatus.visitorStats.at)}</p></article>
<article><h3>Backup Status</h3><p>{stats.writeStatus.backups.ok ? 'Healthy' : 'Error'}</p><p>{formatDate(stats.writeStatus.backups.at)}</p></article>
</div>
<div className="admin-actions admin-actions--maintenance">
<button type="button" className="btn-admin-reset" onClick={onExport}>Export JSON</button>
<button type="button" className="btn-admin-reset" onClick={onBackupNow}>Backup Now</button>
<button type="button" className="btn-admin-reset" onClick={onPrune}>Prune Old Data</button>
<button type="button" className="btn-admin-remove" onClick={onClear}>Clear Analytics</button>
</div>
<div className="admin-actions admin-actions--maintenance">
<button type="button" className="btn-admin-reset" onClick={onPurgeCache}>Purge Cache</button>
<button type="button" className="btn-admin-reset" onClick={onDeployHook}>Trigger Deploy</button>
<button type="button" className="btn-admin-reset" onClick={onRefreshStatus}>Refresh Status</button>
</div>
<div className="admin-restore-row">
<label htmlFor="restore-backup">Restore Backup</label>
<select id="restore-backup" value={selectedBackup} onChange={e => onBackupSelect(e.target.value)} disabled={backupFiles.length === 0}>
{backupFiles.length === 0 && <option value="">No backups found</option>}
{backupFiles.map(file => <option key={file.filename} value={file.filename}>{file.filename}</option>)}
</select>
<button type="button" className="btn-admin-reset" onClick={onRestore} disabled={!selectedBackup}>Restore Selected Backup</button>
</div>
{selectedBackupPreview && (
<div className="admin-restore-preview">
<h3>Restore Preview</h3>
<p><strong>Backup:</strong> {selectedBackupPreview.filename}</p>
<p><strong>Created:</strong> {formatDate(selectedBackupPreview.createdAt)}</p>
<p><strong>Size:</strong> {(selectedBackupPreview.sizeBytes / 1024).toFixed(1)} KB</p>
<p><strong>Total Hits:</strong> {selectedBackupPreview.totalHits.toLocaleString()}</p>
<p><strong>Total Visits:</strong> {selectedBackupPreview.totalVisits.toLocaleString()}</p>
</div>
)}
{/* Full Data Backup (entire /data folder — server migration) */}
<div className="admin-stats-head admin-stats-head--visitors">
<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>
</div>
<div className="admin-actions admin-actions--maintenance">
<button type="button" className="btn-admin-reset" onClick={onDownloadFullBackup} disabled={fullBackupBusy}>
{fullBackupBusy ? 'Working…' : 'Download Full Backup (.tar.gz)'}
</button>
</div>
<div className="admin-restore-row">
<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
id="full-backup-file"
type="file"
onChange={e => setFullBackupFile(e.target.files?.[0] ?? null)}
disabled={fullBackupBusy}
/>
<button
type="button"
className="btn-admin-remove"
onClick={() => { if (fullBackupFile) onImportFullBackup(fullBackupFile) }}
disabled={!fullBackupFile || fullBackupBusy}
>
{fullBackupBusy ? 'Working…' : 'Restore Full Backup'}
</button>
</div>
{maintenanceMsg && <p className="admin-stats-note">{maintenanceMsg}</p>}
</>)}
</section>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { Link } from 'react-router-dom'
export interface BreadcrumbItem {
label: string
href?: string
}
export function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) {
if (items.length === 0) return null
return (
<nav aria-label="Breadcrumb" className="study-breadcrumbs">
<ol className="study-breadcrumbs-list">
{items.map((item, idx) => {
const isLast = idx === items.length - 1
return (
<li key={idx} className="study-breadcrumbs-item">
{!isLast && item.href ? (
<Link to={item.href} className="study-breadcrumbs-link">
{item.label}
</Link>
) : (
<span className="study-breadcrumbs-current" aria-current={isLast ? 'page' : undefined}>
{item.label}
</span>
)}
{!isLast && (
<span className="study-breadcrumbs-sep" aria-hidden="true"> </span>
)}
</li>
)
})}
</ol>
</nav>
)
}
+163
View File
@@ -0,0 +1,163 @@
import { useState, useEffect } from 'react'
import type { ChangeEvent } from 'react'
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,}$/
interface ContactFields {
firstName: string
lastName: string
email: string
message: string
messageType: string
}
export default function ContactForm() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const source = searchParams.get('source')
const studyName = searchParams.get('study')
const [fields, setFields] = useState<ContactFields>({
firstName: '',
lastName: '',
email: '',
message: '',
messageType: source === 'community' ? 'community' : 'question',
})
useEffect(() => {
if (source === 'community' && studyName) {
setFields(f => ({
...f,
messageType: 'community',
message: f.message.startsWith('[From ') ? f.message : `[From ${decodeURIComponent(studyName)} community]\n\n${f.message}`,
}))
}
}, [source, studyName])
const [subscribe, setSubscribe] = useState(true)
const [notifyOnAnswer, setNotifyOnAnswer] = useState(true)
const [honey, setHoney] = useState('')
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [fieldErrors, setFieldErrors] = useState<{ email?: string }>({})
function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
const { name, value } = e.target
setFields(f => ({ ...f, [name]: value }))
if (name === 'email' && fieldErrors.email) {
setFieldErrors(prev => ({ ...prev, email: undefined }))
}
}
function handleEmailBlur() {
if (!fields.email) return
if (!EMAIL_REGEX.test(fields.email)) {
setFieldErrors(prev => ({ ...prev, email: 'Please enter a valid email address.' }))
} else {
setFieldErrors(prev => ({ ...prev, email: undefined }))
}
}
function handleSelectChange(e: ChangeEvent<HTMLSelectElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setStatus('submitting')
setErrorMsg('')
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...fields, subscribe, notifyOnAnswer: fields.messageType === 'question' ? notifyOnAnswer : false, _honey: honey }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setErrorMsg((data as { message?: string }).message ?? 'Something went wrong. Please try again.')
setStatus('error')
return
}
navigate('/thanks')
} catch {
setErrorMsg('Could not connect. Please try again later.')
setStatus('error')
}
}
return (
<form className="contact-form" onSubmit={handleSubmit} noValidate>
<input
type="text"
className="contact-honeypot"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
value={honey}
onChange={e => setHoney(e.target.value)}
/>
<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>
<label>
Email
<input
type="email"
name="email"
required
autoComplete="email"
value={fields.email}
onChange={handleChange}
onBlur={handleEmailBlur}
aria-describedby={fieldErrors.email ? 'contact-email-error' : undefined}
/>
{fieldErrors.email && <span id="contact-email-error" className="study-signup-field-error">{fieldErrors.email}</span>}
</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>
Message
<textarea name="message" rows={6} required value={fields.message} onChange={handleChange} />
</label>
{fields.messageType === 'question' && (
<label className="contact-consent">
<input
type="checkbox"
checked={notifyOnAnswer}
onChange={e => setNotifyOnAnswer(e.target.checked)}
/>
<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>
</label>
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Sending…' : 'Send Message'}
</button>
</form>
)
}
+188
View File
@@ -0,0 +1,188 @@
import { useEffect, useRef, useState } from 'react'
import { sendEvent } from '../analytics'
interface EpisodeAudioPlayerProps {
src: string
title?: string
size?: 'full' | 'compact'
spotifyUrl?: string
}
function formatTime(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return '0:00'
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${String(s).padStart(2, '0')}`
}
function SpotifyLinkIcon({ href }: { href: string }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="episode-audio-player__spotify-link"
aria-label="Listen on Spotify"
>
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm4.586 14.424a.623.623 0 01-.857.207c-2.348-1.435-5.304-1.76-8.785-.964a.623.623 0 11-.277-1.215c3.809-.87 7.076-.496 9.712 1.115a.623.623 0 01.207.857zm1.223-2.722a.779.779 0 01-1.072.257c-2.687-1.652-6.785-2.131-9.965-1.166a.78.78 0 01-.973-.519.779.779 0 01.519-.972c3.632-1.102 8.147-.568 11.234 1.328a.779.779 0 01.257 1.072zm.105-2.835C14.692 8.95 9.375 8.775 6.297 9.71a.935.935 0 11-.543-1.79c3.532-1.072 9.404-.865 13.115 1.338a.935.935 0 01-.955 1.609z"/>
</svg>
</a>
)
}
export function EpisodeAudioPlayer({ src, title, size = 'full', spotifyUrl }: EpisodeAudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null)
const [playing, setPlaying] = useState(false)
const [buffering, setBuffering] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const playTrackedRef = useRef(false)
const listenStartRef = useRef<number | null>(null)
const totalListenSecondsRef = useRef(0)
function flushListenTime() {
if (listenStartRef.current !== null && title) {
const seconds = Math.round((Date.now() - listenStartRef.current) / 1000)
if (seconds > 1) {
totalListenSecondsRef.current += seconds
sendEvent('audio_listen_time', { title, seconds })
}
listenStartRef.current = null
}
}
useEffect(() => {
const audio = audioRef.current
if (!audio) return
const onTimeUpdate = () => setCurrentTime(audio.currentTime)
const onDurationChange = () => setDuration(audio.duration)
const onEnded = () => {
setPlaying(false)
setBuffering(false)
flushListenTime()
if (title) sendEvent('audio_completion', { title })
}
const onWaiting = () => setBuffering(true)
const onCanPlay = () => setBuffering(false)
audio.addEventListener('timeupdate', onTimeUpdate)
audio.addEventListener('durationchange', onDurationChange)
audio.addEventListener('loadedmetadata', onDurationChange)
audio.addEventListener('ended', onEnded)
audio.addEventListener('waiting', onWaiting)
audio.addEventListener('canplay', onCanPlay)
return () => {
audio.removeEventListener('timeupdate', onTimeUpdate)
audio.removeEventListener('durationchange', onDurationChange)
audio.removeEventListener('loadedmetadata', onDurationChange)
audio.removeEventListener('ended', onEnded)
audio.removeEventListener('waiting', onWaiting)
audio.removeEventListener('canplay', onCanPlay)
flushListenTime()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [src])
function togglePlay() {
const audio = audioRef.current
if (!audio) return
if (playing) {
audio.pause()
setPlaying(false)
flushListenTime()
if (title) sendEvent('audio_pause', { title })
} else {
audio.play().then(() => {
setPlaying(true)
listenStartRef.current = Date.now()
if (!playTrackedRef.current && title) {
playTrackedRef.current = true
fetch('/api/analytics/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
}).catch(() => {})
}
}).catch(() => {})
}
}
function seek(e: React.MouseEvent<HTMLDivElement>) {
const audio = audioRef.current
if (!audio || !duration) return
const rect = e.currentTarget.getBoundingClientRect()
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
audio.currentTime = pct * duration
setCurrentTime(pct * duration)
}
const pct = duration > 0 ? (currentTime / duration) * 100 : 0
const isCompact = size === 'compact'
return (
<div className={`episode-audio-player ${isCompact ? 'episode-audio-player--compact' : ''}`}>
<audio ref={audioRef} src={src} preload="metadata" />
<div className="episode-audio-player__inner">
<img
src="/images/podcast-art.jpeg"
alt="Verse by Verse with Nate"
className="episode-audio-player__art"
/>
<div className="episode-audio-player__body">
{!isCompact && (
<div className="episode-audio-player__meta">
<span className="episode-audio-player__show-name">Verse by Verse with Nate</span>
{spotifyUrl && <SpotifyLinkIcon href={spotifyUrl} />}
</div>
)}
{title && (
<p className="episode-audio-player__title">{title}</p>
)}
<div className="episode-audio-player__controls">
<button
className={`episode-audio-player__play${buffering ? ' episode-audio-player__play--buffering' : ''}`}
onClick={togglePlay}
aria-label={playing ? 'Pause' : 'Play'}
>
{playing
? <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
: <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><polygon points="5,3 19,12 5,21"/></svg>
}
</button>
<div className="episode-audio-player__track">
<div
className="episode-audio-player__bar"
onClick={seek}
role="slider"
aria-label="Seek"
aria-valuenow={Math.round(currentTime)}
aria-valuemin={0}
aria-valuemax={Math.round(duration)}
tabIndex={0}
onKeyDown={e => {
const audio = audioRef.current
if (!audio) return
if (e.key === 'ArrowRight') audio.currentTime = Math.min(duration, audio.currentTime + 10)
if (e.key === 'ArrowLeft') audio.currentTime = Math.max(0, audio.currentTime - 10)
}}
>
<div className="episode-audio-player__fill" style={{ width: `${pct}%` }} />
<div className="episode-audio-player__thumb" style={{ left: `${pct}%` }} />
</div>
<div className="episode-audio-player__times">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
{isCompact && spotifyUrl && <SpotifyLinkIcon href={spotifyUrl} />}
</div>
</div>
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More