diff --git a/package.json b/package.json index 30b4ac0..f82d549 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.1.30", + "version": "1.1.31", "type": "module", "scripts": { "dev": "vite", diff --git a/server/helpers.js b/server/helpers.js index 434ea2a..2042dcf 100644 --- a/server/helpers.js +++ b/server/helpers.js @@ -121,6 +121,51 @@ export function sanitizeRedirectRules(value) { return out.length > 0 ? out : DEFAULT_REDIRECT_RULES } +// Returns true if the user's celebration date falls within the current Mon–Sun week. +// Uses birthdayAlternateMonth/Day if set (for summer birthday workarounds), otherwise +// falls back to birthdayMonth/Day. Checks both the current year and next year so +// year-wrap birthdays (e.g. Dec 30 checked in late December) work correctly. +export function isBirthdayThisWeek(user) { + const month = user.birthdayMonth + const day = user.birthdayDay + if (!month || !day) return false + + const celebMonth = user.birthdayAlternateMonth ?? month + const celebDay = user.birthdayAlternateDay ?? day + + const today = new Date() + const monday = new Date(today) + monday.setUTCHours(0, 0, 0, 0) + const dow = monday.getUTCDay() + monday.setUTCDate(monday.getUTCDate() - (dow === 0 ? 6 : dow - 1)) + const sunday = new Date(monday) + sunday.setUTCDate(sunday.getUTCDate() + 6) + sunday.setUTCHours(23, 59, 59, 999) + + const yr = today.getUTCFullYear() + for (const year of [yr, yr + 1]) { + const bday = new Date(Date.UTC(year, celebMonth - 1, celebDay)) + if (bday >= monday && bday <= sunday) return true + } + return false +} + +// June, July, August are considered summer months. +export function isSummerBirthday(month) { + return Number.isInteger(month) && month >= 6 && month <= 8 +} + +// Returns "YYYY-MM-DD" for the celebration date (alternate if set, else birthday) +// in the given year. Returns null if no birthday is set. +export function celebrationDateForYear(user, year) { + const month = user.birthdayMonth + const day = user.birthdayDay + if (!month || !day) return null + const m = user.birthdayAlternateMonth ?? month + const d = user.birthdayAlternateDay ?? day + return `${year}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}` +} + export function sanitizeFeaturedLinks(value) { const source = Array.isArray(value) ? value : [] return source diff --git a/server/reminder.js b/server/reminder.js index 373b8a4..cab55a9 100644 --- a/server/reminder.js +++ b/server/reminder.js @@ -1,7 +1,8 @@ import { Resend } from 'resend' import { state } from './state.js' -import { queuePodcastChecklistWrite, queueCalendarEventsWrite } from './data.js' +import { queuePodcastChecklistWrite, queueCalendarEventsWrite, queueStudyUsersWrite } from './data.js' import { DEFAULT_RESEND_FROM, DEFAULT_RESEND_TO } from './config.js' +import { celebrationDateForYear } from './helpers.js' function toDateKey(date) { return date.toISOString().slice(0, 10) @@ -64,6 +65,63 @@ async function checkReminders() { } if (changed) queueCalendarEventsWrite() } + + // Birthdays β€” fire on the student's celebration day, once per calendar year + const users = state.studyUsers ?? [] + let birthdayUsersChanged = false + const todayYear = new Date().getUTCFullYear() + const birthdayKids = [] + + for (const user of users) { + if (!user.birthdayMonth || !user.birthdayDay) continue + const celebDate = celebrationDateForYear(user, todayYear) + if (!celebDate) continue + if (todayKey !== celebDate) continue + if (user.birthdayEmailSentYear === todayYear) continue + birthdayKids.push({ name: user.displayName || user.username, birthdayMonth: user.birthdayMonth, birthdayDay: user.birthdayDay, hasAlternate: Boolean(user.birthdayAlternateMonth) }) + user.birthdayEmailSentYear = todayYear + birthdayUsersChanged = true + } + + if (birthdayKids.length > 0) { + await sendBirthdayNotificationEmail(birthdayKids) + if (birthdayUsersChanged) queueStudyUsersWrite() + } +} + +async function sendBirthdayNotificationEmail(kids) { + if (!process.env.RESEND_API_KEY) return + 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 MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'] + + const lines = kids.map(k => { + const bdayStr = `${MONTHS[k.birthdayMonth - 1]} ${k.birthdayDay}` + const alt = k.hasAlternate ? ' (celebrating on alternate date)' : '' + return `
  • ${k.name} β€” birthday: ${bdayStr}${alt}
  • ` + }).join('') + + const subject = kids.length === 1 + ? `πŸŽ‚ Birthday today: ${kids[0].name}` + : `πŸŽ‚ ${kids.length} student birthdays today` + + const html = ` +
    +

    πŸŽ‚ Student Birthday Celebration

    +

    The following student${kids.length > 1 ? 's have' : ' has'} a birthday celebration today β€” consider sending a special message or acknowledging them in your study community!

    + +
    +

    To edit birthday info, open the Study Users panel in your admin settings.

    +
    + ` + const { error } = await resend.emails.send({ from, to, subject, html }) + if (error) console.error('[birthday] send error:', error) + else console.log(`[birthday] notification sent for ${kids.map(k => k.name).join(', ')}`) + } catch (err) { + console.error('[birthday] send exception:', err) + } } const TYPE_ICONS = { episode: 'πŸ“…', general: 'πŸ“Œ', recording: 'πŸŽ™οΈ', social: 'πŸ“±', task: 'βœ…' } diff --git a/server/routes/admin-assets.js b/server/routes/admin-assets.js index 0a48bca..d60e566 100644 --- a/server/routes/admin-assets.js +++ b/server/routes/admin-assets.js @@ -1,6 +1,6 @@ import { mkdir, stat, unlink, writeFile } from 'node:fs/promises' import path from 'node:path' -import { inferImageExtensionFromDataUrl, normalizeAssetBaseName } from '../helpers.js' +import { inferImageExtensionFromDataUrl, normalizeAssetBaseName, isBirthdayThisWeek } from '../helpers.js' import { requireAdminAuth } from '../auth.js' import { UPLOADS_DIR } from '../config.js' import { state } from '../state.js' @@ -132,6 +132,11 @@ export function register(app) { noteCount, subscribeNewsletter: user.subscribeNewsletter !== false, studyRemindersEnabled: user.studyRemindersEnabled === true, + birthdayMonth: user.birthdayMonth ?? null, + birthdayDay: user.birthdayDay ?? null, + birthdayAlternateMonth: user.birthdayAlternateMonth ?? null, + birthdayAlternateDay: user.birthdayAlternateDay ?? null, + isBirthdayWeek: isBirthdayThisWeek(user), } })) @@ -142,7 +147,7 @@ export function register(app) { 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 ?? {} + const { displayName, newPassword, addEnrollment, removeEnrollment, birthdayMonth, birthdayDay, birthdayAlternateMonth, birthdayAlternateDay } = req.body ?? {} if (typeof displayName === 'string') { user.displayName = displayName.trim().slice(0, 80) @@ -172,6 +177,28 @@ export function register(app) { user.enrolledStudySlugs = (user.enrolledStudySlugs ?? []).filter(s => s !== slug) } + if (birthdayMonth !== undefined) { + const m = parseInt(birthdayMonth, 10) + user.birthdayMonth = (Number.isInteger(m) && m >= 1 && m <= 12) ? m : null + } + if (birthdayDay !== undefined) { + const d = parseInt(birthdayDay, 10) + user.birthdayDay = (Number.isInteger(d) && d >= 1 && d <= 31) ? d : null + } + if (birthdayAlternateMonth !== undefined) { + const m = parseInt(birthdayAlternateMonth, 10) + user.birthdayAlternateMonth = (Number.isInteger(m) && m >= 1 && m <= 12) ? m : null + if (!user.birthdayAlternateMonth) user.birthdayAlternateDay = null + } + if (birthdayAlternateDay !== undefined) { + const d = parseInt(birthdayAlternateDay, 10) + user.birthdayAlternateDay = (Number.isInteger(d) && d >= 1 && d <= 31) ? d : null + } + // Clear the sent-year so a changed birthday can trigger a new notification + if (birthdayMonth !== undefined || birthdayDay !== undefined || birthdayAlternateMonth !== undefined || birthdayAlternateDay !== undefined) { + user.birthdayEmailSentYear = null + } + user.updatedAt = new Date().toISOString() queueStudyUsersWrite() res.json({ ok: true, displayName: user.displayName, enrolledStudySlugs: user.enrolledStudySlugs }) diff --git a/server/routes/study-auth.js b/server/routes/study-auth.js index 2c2a12b..fb0b3fa 100644 --- a/server/routes/study-auth.js +++ b/server/routes/study-auth.js @@ -6,7 +6,7 @@ import { verifyTotpCode, generateRecoveryCodes, } from '../auth.js' -import { parseCookies } from '../helpers.js' +import { parseCookies, isBirthdayThisWeek } from '../helpers.js' import { STUDY_SESSION_COOKIE, MAX_STUDY_USERS, MAX_CONTACT_SUBMISSIONS } from '../config.js' import { state } from '../state.js' import { @@ -57,6 +57,7 @@ export function register(app) { 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, + isBirthdayWeek: user ? isBirthdayThisWeek(user) : false, }) }) diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index c3cb049..9312457 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -143,6 +143,11 @@ interface StudyUserRecord { noteCount: number subscribeNewsletter: boolean studyRemindersEnabled: boolean + birthdayMonth: number | null + birthdayDay: number | null + birthdayAlternateMonth: number | null + birthdayAlternateDay: number | null + isBirthdayWeek: boolean } function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) { @@ -154,6 +159,8 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) { const [newPassword, setNewPassword] = useState>({}) const [msg, setMsg] = useState>({}) const [confirmDeleteId, setConfirmDeleteId] = useState(null) + const [editBirthday, setEditBirthday] = useState>({}) + function flashMsg(id: string, text: string) { setMsg(prev => ({ ...prev, [id]: text })) @@ -211,6 +218,27 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) { } catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') } } + async function handleSaveBirthday(user: StudyUserRecord) { + const b = editBirthday[user.id] ?? { month: String(user.birthdayMonth ?? ''), day: String(user.birthdayDay ?? ''), altMonth: String(user.birthdayAlternateMonth ?? ''), altDay: String(user.birthdayAlternateDay ?? '') } + const bMonth = b.month ? parseInt(b.month, 10) : null + const bDay = b.day ? parseInt(b.day, 10) : null + const altMonth = b.altMonth ? parseInt(b.altMonth, 10) : null + const altDay = b.altDay ? parseInt(b.altDay, 10) : null + if (bMonth !== null && (bMonth < 1 || bMonth > 12)) { flashMsg(user.id, 'Month must be 1–12.'); return } + if (bDay !== null && (bDay < 1 || bDay > 31)) { flashMsg(user.id, 'Day must be 1–31.'); return } + if ((altMonth == null) !== (altDay == null)) { flashMsg(user.id, 'Set both alternate month and day, or neither.'); return } + try { + await patchUser(user.id, { + birthdayMonth: bMonth !== null ? String(bMonth) : '', + birthdayDay: bDay !== null ? String(bDay) : '', + birthdayAlternateMonth: altMonth !== null ? String(altMonth) : '', + birthdayAlternateDay: altDay !== null ? String(altDay) : '', + }) + setUsers(prev => prev.map(u => u.id !== user.id ? u : { ...u, birthdayMonth: bMonth, birthdayDay: bDay, birthdayAlternateMonth: altMonth, birthdayAlternateDay: altDay })) + flashMsg(user.id, 'Birthday saved.') + } catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') } + } + async function handleUnenroll(user: StudyUserRecord, slug: string) { try { await patchUser(user.id, { removeEnrollment: slug }) @@ -265,6 +293,17 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) { onClick={() => { setExpandedId(isExpanded ? null : user.id) setEditDisplayName(prev => ({ ...prev, [user.id]: user.displayName })) + if (!isExpanded) { + setEditBirthday(prev => ({ + ...prev, + [user.id]: { + month: user.birthdayMonth ? String(user.birthdayMonth) : '', + day: user.birthdayDay ? String(user.birthdayDay) : '', + altMonth: user.birthdayAlternateMonth ? String(user.birthdayAlternateMonth) : '', + altDay: user.birthdayAlternateDay ? String(user.birthdayAlternateDay) : '', + }, + })) + } }} >
    @@ -275,6 +314,7 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) { {user.enrolledStudies.length} enrolled {user.noteCount} notes {(user.currentStreak ?? 0) > 0 && {user.currentStreak} πŸ”₯} + {user.isBirthdayWeek && πŸŽ‚ Birthday Week!} {isExpanded ? 'β–²' : 'β–Ό'}
    @@ -305,6 +345,60 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) { + {/* Birthday */} + {(() => { + const b = editBirthday[user.id] ?? { month: String(user.birthdayMonth ?? ''), day: String(user.birthdayDay ?? ''), altMonth: String(user.birthdayAlternateMonth ?? ''), altDay: String(user.birthdayAlternateDay ?? '') } + const isSummer = (() => { const m = parseInt(b.month, 10); return Number.isInteger(m) && m >= 6 && m <= 8 })() + const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] + function bdayUpdate(field: string, val: string) { + setEditBirthday(prev => ({ ...prev, [user.id]: { ...(prev[user.id] ?? { month: '', day: '', altMonth: '', altDay: '' }), [field]: val } })) + } + return ( +
    +

    Birthday

    +

    Admin-only. Used for birthday celebration notifications and the student's birthday week banner.

    +
    + + + + {(b.month && b.day) && ( + + )} +
    + {isSummer && ( +
    +

    β˜€οΈ Summer birthday β€” this falls during summer break (June–August). Set an alternate school-year celebration date below so they still get recognized during the school year.

    +
    + + +
    + {(b.altMonth && b.altDay) &&

    Birthday banner and email will fire on {MONTHS[parseInt(b.altMonth, 10) - 1]} {b.altDay} instead of their summer birthday.

    } +
    + )} + {user.isBirthdayWeek && ( +

    πŸŽ‚ This student's celebration week is right now!

    + )} +
    + ) + })()} + {/* Password Reset */}

    Reset Password

    diff --git a/src/colossiansStudy.tsx b/src/colossiansStudy.tsx index c9dca17..6fe5f15 100644 --- a/src/colossiansStudy.tsx +++ b/src/colossiansStudy.tsx @@ -23,6 +23,7 @@ type StudyAuthState = { totpEnabled?: boolean twoFaMethod?: 'app' | 'email' | null totpRecoveryCodesRemaining?: number + isBirthdayWeek?: boolean } type StudyAuthStatusResponse = { @@ -36,6 +37,7 @@ type StudyAuthStatusResponse = { totpEnabled?: boolean twoFaMethod?: 'app' | 'email' | null totpRecoveryCodesRemaining?: number + isBirthdayWeek?: boolean } type StudyAccountOverview = { @@ -1258,6 +1260,10 @@ export function ColossiansStudyIndexPage({ content }: Props) { const [enrollMessage, setEnrollMessage] = useState('') const [studyProgress, setStudyProgress] = useState({ completedSectionIds: [] }) const [progressLoading, setProgressLoading] = useState(false) + const [birthdayBannerDismissed, setBirthdayBannerDismissed] = useState(() => { + const yr = new Date().getFullYear() + return localStorage.getItem(`birthday-banner-dismissed-${yr}`) === '1' + }) useEffect(() => { let cancelled = false @@ -1268,7 +1274,9 @@ export function ColossiansStudyIndexPage({ content }: Props) { checked: true, authenticated: Boolean(data.authenticated), username: data.username ?? '', + displayName: data.displayName ?? '', enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs), + isBirthdayWeek: data.isBirthdayWeek === true, }) }) .catch(() => { @@ -1351,6 +1359,19 @@ export function ColossiansStudyIndexPage({ content }: Props) { return (
    + {auth.authenticated && auth.isBirthdayWeek && !birthdayBannerDismissed && ( +
    + + πŸŽ‚ Happy Birthday{auth.displayName ? `, ${auth.displayName}` : ''}! Wishing you a wonderful birthday week β€” we're so glad you're part of this study community. πŸŽ‰ + + +
    + )}