From e158e1e53af9432a1bd00f6d934f335395445217 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Thu, 17 Sep 2026 16:10:31 -0400 Subject: [PATCH] Codebase audit: remove dead code, fix broken patterns, consolidate duplicates; v2.0.27 - Remove unused prop-types dep, deprecated useOutboundLinkTracking export - Fix missing linkClicks key in analyticsEvents initial state (B-01) - Export ADMIN_SESSION_COOKIE from auth.js; remove local copy in admin-auth.js (D-02) - Remove cookieFlags() wrapper in auth.js and study-helpers.js; use cookieSecureFlag() directly (U-04) - Remove ADMIN_SESSION_TTL_MS duplicate from config.js (D-01) - Remove DEFAULT_SEO/DEFAULT_LEGAL/DEFAULT_REDIRECT_RULES/DEFAULT_PODCAST_FEATURED_LINKS/DEFAULT_PUBLISH_STATE duplicates from config.js; re-export from helpers.js (D-04, fixes sitemapPaths divergence) - Fix email.js hardcoded fallback strings to use DEFAULT_RESEND_TO/DEFAULT_RESEND_REPLY_TO (M-02) - Remove duplicate normalizeIp, isPrivateOrLocalIp, normalizeRedirectPath, sanitizeUrl, sanitizeRedirectRules bodies from study-helpers.js; re-export from helpers.js (D-03) - Fix pruneStatsByDays to import VISITOR_RETENTION_DAYS_DEFAULT from config.js (B-02) - Fix createTitusDownloadToken to use DOWNLOAD_TOKEN_TTL_MS constant (B-03) - Remove normalizeMessageType duplicate from study-helpers.js (U-03/D-08) - Remove dead wantsWelcome variable in contact.js; use shouldSendWelcome (B-04) - Replace dynamic imports in contact.js with static imports (B-07) - Remove dead wantsWelcome=true in study-auth.js (B-05) - Merge duplicate import blocks in analytics.js and public.js (D-05, D-06) - Replace dynamic readFile re-import in nuggets-host-upload.js (B-06) - Replace hardcoded SITE_URL fallbacks with getCanonicalBaseUrl() (M-06) - Replace raw resend.emails.send in study-account.js with sendResendEmailWithRetry (M-01) Co-Authored-By: Claude Sonnet 4.6 --- package.json | 3 +- server/auth.js | 10 +-- server/config.js | 54 +--------------- server/email.js | 6 +- server/routes/admin-auth.js | 3 +- server/routes/analytics.js | 2 +- server/routes/contact.js | 12 ++-- server/routes/nuggets-host-upload.js | 8 +-- server/routes/public.js | 2 +- server/routes/study-account.js | 38 +++++------ server/routes/study-auth.js | 5 +- server/state.js | 1 + server/study-helpers.js | 97 +++++----------------------- src/analytics.ts | 2 - 14 files changed, 58 insertions(+), 185 deletions(-) diff --git a/package.json b/package.json index b61f840..52a531e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.0.26", + "version": "2.0.27", "type": "module", "scripts": { "dev": "vite", @@ -26,7 +26,6 @@ "mammoth": "^1.12.0", "multer": "^2.3.0", "otplib": "^13.4.0", - "prop-types": "^15.8.1", "qrcode": "^1.5.4", "react": "^19.2.4", "react-chartjs-2": "^5.2.0", diff --git a/server/auth.js b/server/auth.js index 5a63838..9dc4a63 100644 --- a/server/auth.js +++ b/server/auth.js @@ -11,16 +11,12 @@ const TOTP_SECRET_FILE = path.join(DATA_DIR, 'totp-secret.json') const TOTP_PENDING_TTL_MS = 5 * 60 * 1000 const totpPendingSessions = new Map() -const ADMIN_SESSION_COOKIE = 'vbn_admin_session' +export const ADMIN_SESSION_COOKIE = 'vbn_admin_session' const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000 const ADMIN_SESSION_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') } @@ -254,14 +250,14 @@ export function isValidAdminSession(req) { 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()}`, + `${ADMIN_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`, ) } export function clearAdminSessionCookie(res) { res.append( 'Set-Cookie', - `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`, + `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`, ) } diff --git a/server/config.js b/server/config.js index e841999..4d52c9c 100644 --- a/server/config.js +++ b/server/config.js @@ -5,6 +5,7 @@ import { validateAdminPasswordSetup } from './auth.js' import { ROOT_DIR, DATA_DIR } from './paths.js' export { ROOT_DIR, DATA_DIR } +export { DEFAULT_SEO, DEFAULT_LEGAL, DEFAULT_REDIRECT_RULES, DEFAULT_PODCAST_FEATURED_LINKS, DEFAULT_PUBLISH_STATE } from './helpers.js' function readPackageVersion() { try { @@ -103,7 +104,6 @@ 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) @@ -132,58 +132,6 @@ export const NATE_RESEND_FROM = 'Verse by Verse with Nate 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', diff --git a/server/email.js b/server/email.js index e9e97e3..cc53b6a 100644 --- a/server/email.js +++ b/server/email.js @@ -2,6 +2,8 @@ import { Resend } from 'resend' import { escapeHtml, buildAbsoluteUrl, splitName } from './helpers.js' import { DEFAULT_RESEND_FROM, + DEFAULT_RESEND_TO, + DEFAULT_RESEND_REPLY_TO, DEFAULT_SEO, } from './config.js' import { state } from './state.js' @@ -13,11 +15,11 @@ export function getResendFromAddress() { } export function getResendReplyToAddress() { - return process.env.RESEND_REPLY_TO ?? 'hello@versebyversewithnate.us' + return process.env.RESEND_REPLY_TO ?? DEFAULT_RESEND_REPLY_TO } export function getResendInboxAddress() { - return process.env.RESEND_TO ?? 'hello@versebyversewithnate.us' + return process.env.RESEND_TO ?? DEFAULT_RESEND_TO } export function getCanonicalBaseUrl() { diff --git a/server/routes/admin-auth.js b/server/routes/admin-auth.js index 63dd6af..88ed234 100644 --- a/server/routes/admin-auth.js +++ b/server/routes/admin-auth.js @@ -3,6 +3,7 @@ import qrcode from 'qrcode' import { parseCookies } from '../helpers.js' import { APP_VERSION, GIT_COMMIT } from '../config.js' import { + ADMIN_SESSION_COOKIE, isAdminPasswordConfigured, isValidAdminSession, requireAdminAuth, @@ -24,8 +25,6 @@ import { consumePendingSession, } from '../auth.js' -const ADMIN_SESSION_COOKIE = 'vbn_admin_session' - const loginRateLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, diff --git a/server/routes/analytics.js b/server/routes/analytics.js index c65b410..2fa1027 100644 --- a/server/routes/analytics.js +++ b/server/routes/analytics.js @@ -19,8 +19,8 @@ import { buildLastNDaysStats, shouldCountHit, recordHit, + getStudyCatalog, } from '../study-helpers.js' -import { getStudyCatalog } from '../study-helpers.js' async function resolveGeo(ip) { if (!ip || isPrivateOrLocalIp(ip)) { diff --git a/server/routes/contact.js b/server/routes/contact.js index 3876d3f..c98ced3 100644 --- a/server/routes/contact.js +++ b/server/routes/contact.js @@ -1,12 +1,13 @@ import { randomUUID } from 'node:crypto' import { Resend } from 'resend' import { requireAdminAuth } from '../auth.js' -import { escapeHtml, splitName } from '../helpers.js' +import { escapeHtml, splitName, buildAbsoluteUrl } from '../helpers.js' import { MAX_CONTACT_SUBMISSIONS, MAX_QUESTIONS, USE_RESEND_AUTOMATION_WELCOME, DEFAULT_SEO, + DATA_FILE, } from '../config.js' import { state } from '../state.js' import { @@ -16,6 +17,7 @@ import { normalizeContactEmailStatus, normalizeMessageType, fireWebhooks, + loadSiteContentFile, } from '../data.js' import { noteContactEmailCooldown, @@ -130,7 +132,6 @@ export function register(app) { 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(), @@ -138,9 +139,9 @@ export function register(app) { email: trimmedEmail, message: trimmedMessage, messageType: normalizedMessageType, - subscribe: wantsWelcome, + subscribe: shouldSendWelcome, archived: false, - emailStatus: normalizeContactEmailStatus(null, wantsWelcome), + emailStatus: normalizeContactEmailStatus(null, shouldSendWelcome), } state.contactSubmissions.unshift(submission) state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS) @@ -187,8 +188,6 @@ export function register(app) { 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 } @@ -200,7 +199,6 @@ export function register(app) { : 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, diff --git a/server/routes/nuggets-host-upload.js b/server/routes/nuggets-host-upload.js index b643bed..ce0d9a9 100644 --- a/server/routes/nuggets-host-upload.js +++ b/server/routes/nuggets-host-upload.js @@ -10,6 +10,7 @@ import { sendNuggetsUploadNotification, sendNuggetsHostWelcomeEmail, sendNuggetsHostOtpEmail, + getCanonicalBaseUrl, } from '../email.js' import { markSeafileFileSeen } from '../nuggets-seafile-watcher.js' import { @@ -414,8 +415,7 @@ export function register(app) { } try { - const { readFile: rf } = await import('node:fs/promises') - const fileBuffer = await rf(tempPath) + const fileBuffer = await readFile(tempPath) // Upload audio to /Episode Upload/ const audioLink = await getSeafileUploadLink(SEAFILE_EPISODE_UPLOAD_DIR) @@ -553,7 +553,7 @@ export function register(app) { name, username: email, password, - portalUrl: `${process.env.SITE_URL ?? 'https://versebyversewithnate.us'}/host`, + portalUrl: `${getCanonicalBaseUrl().replace(/\/$/, '')}/host`, }).catch(err => console.error('[nuggets-host] welcome email error:', err)) const { passwordHash: _ph, ...safeHost } = host @@ -591,7 +591,7 @@ export function register(app) { name: data.hosts[idx].name, username: data.hosts[idx].email, password: newPassword, - portalUrl: `${process.env.SITE_URL ?? 'https://versebyversewithnate.us'}/host`, + portalUrl: `${getCanonicalBaseUrl().replace(/\/$/, '')}/host`, }).catch(err => console.error('[nuggets-host] reset email error:', err)) } diff --git a/server/routes/public.js b/server/routes/public.js index 47568ad..a25449f 100644 --- a/server/routes/public.js +++ b/server/routes/public.js @@ -11,10 +11,10 @@ import { DEFAULT_SEO, APP_VERSION, GIT_COMMIT, + DATA_FILE, } 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' import { fetchAllEpisodes } from './episodes.js' import { fetchNuggetsEpisodes } from './nuggets.js' diff --git a/server/routes/study-account.js b/server/routes/study-account.js index 8dedda8..a444f53 100644 --- a/server/routes/study-account.js +++ b/server/routes/study-account.js @@ -37,6 +37,7 @@ import { buildBrandedEmailHtml, getCanonicalBaseUrl, getResendFromAddress, + sendResendEmailWithRetry, } from '../email.js' import { Resend } from 'resend' @@ -381,27 +382,26 @@ export function register(app) { 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: `

${escapeHtml(emailChangeBody)}

`, - ctaLabel: emailChangeCtaLabel, - ctaUrl: verifyUrl, - footerHtml: `

Verse by Verse with Nate

`, - }), + await sendResendEmailWithRetry({ + resend, + context: 'study-account-email-change', + payload: { + from: getResendFromAddress(), + to: [newEmail], + subject: emailChangeSubject, + text: `${emailChangeBody}\n\n${verifyUrl}`, + html: buildBrandedEmailHtml({ + title: emailChangeSubject, + eyebrow: 'Account Security', + bodyHtml: `

${escapeHtml(emailChangeBody)}

`, + ctaLabel: emailChangeCtaLabel, + ctaUrl: verifyUrl, + footerHtml: `

Verse by Verse with Nate

`, + }), + }, }) - 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) + console.error('[study-account] email change send error:', err) res.status(503).json({ message: 'Could not send verification email right now.' }) return } diff --git a/server/routes/study-auth.js b/server/routes/study-auth.js index 2c2a12b..5211b50 100644 --- a/server/routes/study-auth.js +++ b/server/routes/study-auth.js @@ -105,7 +105,6 @@ export function register(app) { queueStudyUsersWrite() if (subscribe) { - const wantsWelcome = true const submission = { id: randomUUID(), submittedAt: now, @@ -113,9 +112,9 @@ export function register(app) { email: username, message: '', messageType: 'general', - subscribe: wantsWelcome, + subscribe: true, archived: false, - emailStatus: normalizeContactEmailStatus(null, wantsWelcome), + emailStatus: normalizeContactEmailStatus(null, true), } state.contactSubmissions.unshift(submission) state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS) diff --git a/server/state.js b/server/state.js index 1f83a66..1c895da 100644 --- a/server/state.js +++ b/server/state.js @@ -81,6 +81,7 @@ export const state = { searchQueries: {}, notFound: {}, audioEvents: {}, + linkClicks: {}, }, analyticsEventsWritePromise: Promise.resolve(), diff --git a/server/study-helpers.js b/server/study-helpers.js index c223cfc..1d6aac8 100644 --- a/server/study-helpers.js +++ b/server/study-helpers.js @@ -1,6 +1,15 @@ import { createHash, createHmac, randomUUID } from 'node:crypto' import path from 'node:path' -import { parseCookies, cookieSecureFlag } from './helpers.js' +import { + parseCookies, + cookieSecureFlag, + normalizeIp, + isPrivateOrLocalIp, + normalizeRedirectPath, + sanitizeUrl, + sanitizeRedirectRules, +} from './helpers.js' +export { normalizeIp, isPrivateOrLocalIp, normalizeRedirectPath, sanitizeUrl, sanitizeRedirectRules } from './helpers.js' import { STUDY_SESSION_COOKIE, STUDY_SESSION_TTL_MS, @@ -9,6 +18,8 @@ import { EMAIL_OTP_MAX_ATTEMPTS, CONTACT_EMAIL_COOLDOWN_MS, DEFAULT_REDIRECT_RULES, + VISITOR_RETENTION_DAYS_DEFAULT, + DOWNLOAD_TOKEN_TTL_MS, } from './config.js' import { state } from './state.js' import { queueStudyRemindersWrite, queueStudyUsersWrite } from './data.js' @@ -130,10 +141,6 @@ export function getStudyAvatarUrl(subject) { // ── 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 }) @@ -143,14 +150,14 @@ export function createStudySession(userId) { 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()}`, + `${STUDY_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(STUDY_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`, ) } export function clearStudySessionCookie(res) { res.append( 'Set-Cookie', - `${STUDY_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`, + `${STUDY_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`, ) } @@ -233,7 +240,7 @@ export function createTitusDownloadToken(email) { const token = randomUUID() state.titusDownloadTokens.set(token, { email, - expiresAt: Date.now() + (10 * 60 * 1000), + expiresAt: Date.now() + DOWNLOAD_TOKEN_TTL_MS, }) return token } @@ -381,29 +388,6 @@ export function detectBot(userAgent) { 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) { @@ -455,7 +439,6 @@ export function recordHit(pathname, isBot = false, botReason = null) { } 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 @@ -505,51 +488,6 @@ export function pruneStatsByDays(daysRaw) { } } -// ── Redirect / URL helpers ───────────────────────────────────────────────── - -export function normalizeRedirectPath(value) { - if (typeof value !== 'string') return '' - const trimmed = value.trim() - if (!trimmed) return '' - const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}` - const normalized = withSlash.replace(/\/+/g, '/') - if (normalized === '/') return '' - if (normalized.startsWith('/api/') || normalized.startsWith('/admin')) return '' - return normalized -} - -export function sanitizeUrl(value) { - if (typeof value !== 'string') return '' - const trimmed = value.trim() - if (!trimmed) return '' - if (trimmed.startsWith('/')) return trimmed - if (/^https?:\/\//i.test(trimmed)) return trimmed - return '' -} - -export function sanitizeRedirectRules(value) { - const source = Array.isArray(value) ? value : [] - const seen = new Set() - const out = [] - - for (const item of source) { - const pathValue = normalizeRedirectPath(item?.path) - const target = sanitizeUrl(item?.target) - const statusCode = Number(item?.statusCode) === 302 ? 302 : 301 - if (!pathValue || !target) continue - if (seen.has(pathValue)) continue - seen.add(pathValue) - out.push({ - id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), - path: pathValue, - target, - statusCode, - }) - } - - return out.length > 0 ? out : DEFAULT_REDIRECT_RULES -} - // ── Contact / email tracking ─────────────────────────────────────────────── export function noteContactEmailCooldown(emailAddress) { @@ -609,11 +547,6 @@ export function extractResendMessageId(result) { return '' } -export function normalizeMessageType(value) { - if (value === 'question' || value === 'testimony' || value === 'topic') return value - return 'general' -} - // ── Study reminder scheduler ─────────────────────────────────────────────── export async function scheduleStudyReminders(sendStudyReminderEmail) { diff --git a/src/analytics.ts b/src/analytics.ts index 54dea24..b16a6a6 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -125,5 +125,3 @@ export function useLinkClickTracking() { }, []) } -/** @deprecated Use useLinkClickTracking instead */ -export function useOutboundLinkTracking() { useLinkClickTracking() }