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>
This commit is contained in:
@@ -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
|
||||
|
||||
+59
-1
@@ -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 `<li style="margin-bottom:6px"><strong>${k.name}</strong> — birthday: ${bdayStr}${alt}</li>`
|
||||
}).join('')
|
||||
|
||||
const subject = kids.length === 1
|
||||
? `🎂 Birthday today: ${kids[0].name}`
|
||||
: `🎂 ${kids.length} student birthdays today`
|
||||
|
||||
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">🎂 Student Birthday Celebration</h2>
|
||||
<p>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!</p>
|
||||
<ul style="padding-left:1.2em">${lines}</ul>
|
||||
<hr style="border:none;border-top:1px solid #eee;margin:16px 0">
|
||||
<p style="color:#777;font-size:0.85rem">To edit birthday info, open the Study Users panel in your admin settings.</p>
|
||||
</div>
|
||||
`
|
||||
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: '✅' }
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user