Files
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

253 lines
10 KiB
JavaScript

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],
}
}