Implemented a configurable retention policy system for sermons with automatic cleanup: - Added settings table to store retention policy configuration - Created API endpoints for getting/setting retention policy - Added Database Settings section to admin page with retention options (forever, 1-10 years) - Implemented manual cleanup endpoint for on-demand deletion - Added automated daily cleanup task via Nitro plugin - Sermons are deleted based on their date field according to the retention policy 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
402 lines
12 KiB
TypeScript
402 lines
12 KiB
TypeScript
import Database from 'better-sqlite3'
|
|
import { join } from 'path'
|
|
import bcrypt from 'bcrypt'
|
|
|
|
let db: Database.Database | null = null
|
|
|
|
export interface Sermon {
|
|
id?: number
|
|
slug: string
|
|
title: string
|
|
date: string
|
|
dates?: string
|
|
bible_references: string
|
|
personal_appliance: string
|
|
pastors_challenge: string
|
|
worship_songs?: string
|
|
created_by?: string
|
|
created_at?: string
|
|
}
|
|
|
|
export interface User {
|
|
id?: number
|
|
username: string
|
|
password: string
|
|
email?: string
|
|
first_name?: string
|
|
last_name?: string
|
|
is_admin: number
|
|
}
|
|
|
|
export interface PasswordResetCode {
|
|
id?: number
|
|
email: string
|
|
code: string
|
|
expires_at: string
|
|
created_at?: string
|
|
}
|
|
|
|
export interface SermonNote {
|
|
id?: number
|
|
user_id: number
|
|
sermon_id: number
|
|
notes: string
|
|
created_at?: string
|
|
updated_at?: string
|
|
}
|
|
|
|
export interface Session {
|
|
id?: number
|
|
token: string
|
|
username: string
|
|
expires_at: string
|
|
created_at?: string
|
|
}
|
|
|
|
export interface RateLimit {
|
|
id?: number
|
|
identifier: string
|
|
endpoint: string
|
|
attempts: number
|
|
reset_at: string
|
|
}
|
|
|
|
export interface Setting {
|
|
id?: number
|
|
key: string
|
|
value: string
|
|
updated_at?: string
|
|
}
|
|
|
|
export function getDatabase() {
|
|
if (!db) {
|
|
const dbPath = join(process.cwd(), 'data', 'sermons.db')
|
|
db = new Database(dbPath)
|
|
|
|
// Create tables if they don't exist
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS sermons (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
slug TEXT UNIQUE NOT NULL,
|
|
title TEXT NOT NULL,
|
|
date TEXT NOT NULL,
|
|
dates TEXT,
|
|
bible_references TEXT NOT NULL,
|
|
personal_appliance TEXT NOT NULL,
|
|
pastors_challenge TEXT NOT NULL,
|
|
worship_songs TEXT,
|
|
created_by TEXT,
|
|
archived INTEGER DEFAULT 0,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`)
|
|
|
|
// Add created_by column if it doesn't exist (migration)
|
|
try {
|
|
db.exec(`ALTER TABLE sermons ADD COLUMN created_by TEXT`)
|
|
} catch (e) {
|
|
// Column already exists, ignore error
|
|
}
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT UNIQUE NOT NULL,
|
|
password TEXT NOT NULL,
|
|
email TEXT,
|
|
first_name TEXT,
|
|
last_name TEXT,
|
|
is_admin INTEGER DEFAULT 0
|
|
)
|
|
`)
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS password_reset_codes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
email TEXT NOT NULL,
|
|
code TEXT NOT NULL,
|
|
expires_at DATETIME NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`)
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS sermon_notes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
sermon_id INTEGER NOT NULL,
|
|
notes TEXT NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (sermon_id) REFERENCES sermons(id) ON DELETE CASCADE,
|
|
UNIQUE(user_id, sermon_id)
|
|
)
|
|
`)
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
token TEXT UNIQUE NOT NULL,
|
|
username TEXT NOT NULL,
|
|
expires_at DATETIME NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`)
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS rate_limits (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
identifier TEXT NOT NULL,
|
|
endpoint TEXT NOT NULL,
|
|
attempts INTEGER DEFAULT 1,
|
|
reset_at DATETIME NOT NULL,
|
|
UNIQUE(identifier, endpoint)
|
|
)
|
|
`)
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
key TEXT UNIQUE NOT NULL,
|
|
value TEXT NOT NULL,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`)
|
|
|
|
// Insert default admin user from environment variables with hashed password
|
|
const config = useRuntimeConfig()
|
|
const adminUsername = config.adminUsername
|
|
const adminPassword = config.adminPassword
|
|
|
|
const userExists = db.prepare('SELECT COUNT(*) as count FROM users WHERE username = ?').get(adminUsername) as { count: number }
|
|
if (userExists.count === 0) {
|
|
// Hash the password before storing
|
|
const saltRounds = 10
|
|
const hashedPassword = bcrypt.hashSync(adminPassword, saltRounds)
|
|
db.prepare('INSERT INTO users (username, password, is_admin) VALUES (?, ?, 1)').run(adminUsername, hashedPassword)
|
|
}
|
|
}
|
|
|
|
return db
|
|
}
|
|
|
|
export function getAllSermons(limit?: number, includeArchived: boolean = false) {
|
|
const db = getDatabase()
|
|
const whereClause = includeArchived ? '' : 'WHERE archived = 0'
|
|
|
|
if (limit) {
|
|
return db.prepare(`SELECT * FROM sermons ${whereClause} ORDER BY date DESC LIMIT ?`).all(limit) as Sermon[]
|
|
}
|
|
return db.prepare(`SELECT * FROM sermons ${whereClause} ORDER BY date DESC`).all() as Sermon[]
|
|
}
|
|
|
|
export function getArchivedSermons() {
|
|
const db = getDatabase()
|
|
return db.prepare('SELECT * FROM sermons WHERE archived = 1 ORDER BY date DESC').all() as Sermon[]
|
|
}
|
|
|
|
export function archiveSermon(id: number) {
|
|
const db = getDatabase()
|
|
return db.prepare('UPDATE sermons SET archived = 1 WHERE id = ?').run(id)
|
|
}
|
|
|
|
export function getSermonBySlug(slug: string) {
|
|
const db = getDatabase()
|
|
return db.prepare('SELECT * FROM sermons WHERE slug = ?').get(slug) as Sermon | undefined
|
|
}
|
|
|
|
export function createSermon(sermon: Sermon) {
|
|
const db = getDatabase()
|
|
const stmt = db.prepare(`
|
|
INSERT INTO sermons (slug, title, date, dates, bible_references, personal_appliance, pastors_challenge, worship_songs, created_by)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`)
|
|
return stmt.run(
|
|
sermon.slug,
|
|
sermon.title,
|
|
sermon.date,
|
|
sermon.dates || null,
|
|
sermon.bible_references,
|
|
sermon.personal_appliance,
|
|
sermon.pastors_challenge,
|
|
sermon.worship_songs || null,
|
|
sermon.created_by || null
|
|
)
|
|
}
|
|
|
|
export function getUserByUsername(username: string) {
|
|
const db = getDatabase()
|
|
return db.prepare('SELECT * FROM users WHERE username = ?').get(username) as User | undefined
|
|
}
|
|
|
|
export function createUser(username: string, password: string, email?: string, firstName?: string, lastName?: string) {
|
|
const db = getDatabase()
|
|
const saltRounds = 10
|
|
const hashedPassword = bcrypt.hashSync(password, saltRounds)
|
|
return db.prepare('INSERT INTO users (username, password, email, first_name, last_name, is_admin) VALUES (?, ?, ?, ?, ?, 0)')
|
|
.run(username, hashedPassword, email || null, firstName || null, lastName || null)
|
|
}
|
|
|
|
export function getUserByEmail(email: string) {
|
|
const db = getDatabase()
|
|
return db.prepare('SELECT * FROM users WHERE email = ?').get(email) as User | undefined
|
|
}
|
|
|
|
export function createPasswordResetCode(email: string, code: string, expiresAt: string) {
|
|
const db = getDatabase()
|
|
// Delete any existing codes for this email
|
|
db.prepare('DELETE FROM password_reset_codes WHERE email = ?').run(email)
|
|
return db.prepare('INSERT INTO password_reset_codes (email, code, expires_at) VALUES (?, ?, ?)')
|
|
.run(email, code, expiresAt)
|
|
}
|
|
|
|
export function getPasswordResetCode(email: string, code: string) {
|
|
const db = getDatabase()
|
|
return db.prepare("SELECT * FROM password_reset_codes WHERE email = ? AND code = ? AND expires_at > datetime('now')")
|
|
.get(email, code) as PasswordResetCode | undefined
|
|
}
|
|
|
|
export function deletePasswordResetCode(email: string) {
|
|
const db = getDatabase()
|
|
return db.prepare('DELETE FROM password_reset_codes WHERE email = ?').run(email)
|
|
}
|
|
|
|
export function resetPasswordByEmail(email: string, newPassword: string) {
|
|
const db = getDatabase()
|
|
const saltRounds = 10
|
|
const hashedPassword = bcrypt.hashSync(newPassword, saltRounds)
|
|
const result = db.prepare('UPDATE users SET password = ? WHERE LOWER(email) = LOWER(?)').run(hashedPassword, email)
|
|
return result
|
|
}
|
|
|
|
export function getAllUsers() {
|
|
const db = getDatabase()
|
|
return db.prepare('SELECT id, username, email, first_name, last_name, is_admin FROM users ORDER BY username').all() as Omit<User, 'password'>[]
|
|
}
|
|
|
|
export function deleteUser(id: number) {
|
|
const db = getDatabase()
|
|
return db.prepare('DELETE FROM users WHERE id = ?').run(id)
|
|
}
|
|
|
|
export function updateUserRole(id: number, isAdmin: number) {
|
|
const db = getDatabase()
|
|
return db.prepare('UPDATE users SET is_admin = ? WHERE id = ?').run(isAdmin, id)
|
|
}
|
|
|
|
export function resetUserPassword(id: number, newPassword: string) {
|
|
const db = getDatabase()
|
|
const saltRounds = 10
|
|
const hashedPassword = bcrypt.hashSync(newPassword, saltRounds)
|
|
return db.prepare('UPDATE users SET password = ? WHERE id = ?').run(hashedPassword, id)
|
|
}
|
|
|
|
export function getSermonNote(userId: number, sermonId: number) {
|
|
const db = getDatabase()
|
|
return db.prepare('SELECT * FROM sermon_notes WHERE user_id = ? AND sermon_id = ?').get(userId, sermonId) as SermonNote | undefined
|
|
}
|
|
|
|
export function saveSermonNote(userId: number, sermonId: number, notes: string) {
|
|
const db = getDatabase()
|
|
const existing = getSermonNote(userId, sermonId)
|
|
|
|
if (existing) {
|
|
return db.prepare('UPDATE sermon_notes SET notes = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND sermon_id = ?').run(notes, userId, sermonId)
|
|
} else {
|
|
return db.prepare('INSERT INTO sermon_notes (user_id, sermon_id, notes) VALUES (?, ?, ?)').run(userId, sermonId, notes)
|
|
}
|
|
}
|
|
|
|
export function deleteSermonNote(userId: number, sermonId: number) {
|
|
const db = getDatabase()
|
|
return db.prepare('DELETE FROM sermon_notes WHERE user_id = ? AND sermon_id = ?').run(userId, sermonId)
|
|
}
|
|
|
|
// Session management functions
|
|
export function createSession(token: string, username: string, expiresAt: string) {
|
|
const db = getDatabase()
|
|
return db.prepare('INSERT INTO sessions (token, username, expires_at) VALUES (?, ?, ?)')
|
|
.run(token, username, expiresAt)
|
|
}
|
|
|
|
export function getSessionByToken(token: string) {
|
|
const db = getDatabase()
|
|
return db.prepare("SELECT * FROM sessions WHERE token = ? AND expires_at > datetime('now')")
|
|
.get(token) as Session | undefined
|
|
}
|
|
|
|
export function deleteSession(token: string) {
|
|
const db = getDatabase()
|
|
return db.prepare('DELETE FROM sessions WHERE token = ?').run(token)
|
|
}
|
|
|
|
export function deleteExpiredSessions() {
|
|
const db = getDatabase()
|
|
return db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run()
|
|
}
|
|
|
|
// Rate limiting functions
|
|
export function checkRateLimit(identifier: string, endpoint: string, maxAttempts: number, windowMinutes: number): boolean {
|
|
const db = getDatabase()
|
|
|
|
// Clean up expired rate limit records
|
|
db.prepare("DELETE FROM rate_limits WHERE reset_at <= datetime('now')").run()
|
|
|
|
const existing = db.prepare('SELECT * FROM rate_limits WHERE identifier = ? AND endpoint = ?')
|
|
.get(identifier, endpoint) as RateLimit | undefined
|
|
|
|
if (!existing) {
|
|
// First attempt - create new record
|
|
const resetAt = new Date(Date.now() + windowMinutes * 60 * 1000).toISOString()
|
|
db.prepare('INSERT INTO rate_limits (identifier, endpoint, attempts, reset_at) VALUES (?, ?, 1, ?)')
|
|
.run(identifier, endpoint, resetAt)
|
|
return true
|
|
}
|
|
|
|
if (existing.attempts >= maxAttempts) {
|
|
// Rate limit exceeded
|
|
return false
|
|
}
|
|
|
|
// Increment attempts
|
|
db.prepare('UPDATE rate_limits SET attempts = attempts + 1 WHERE identifier = ? AND endpoint = ?')
|
|
.run(identifier, endpoint)
|
|
return true
|
|
}
|
|
|
|
export function resetRateLimit(identifier: string, endpoint: string) {
|
|
const db = getDatabase()
|
|
return db.prepare('DELETE FROM rate_limits WHERE identifier = ? AND endpoint = ?')
|
|
.run(identifier, endpoint)
|
|
}
|
|
|
|
// Settings management functions
|
|
export function getSetting(key: string) {
|
|
const db = getDatabase()
|
|
return db.prepare('SELECT * FROM settings WHERE key = ?').get(key) as Setting | undefined
|
|
}
|
|
|
|
export function setSetting(key: string, value: string) {
|
|
const db = getDatabase()
|
|
const existing = getSetting(key)
|
|
|
|
if (existing) {
|
|
return db.prepare('UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?').run(value, key)
|
|
} else {
|
|
return db.prepare('INSERT INTO settings (key, value) VALUES (?, ?)').run(key, value)
|
|
}
|
|
}
|
|
|
|
// Sermon retention policy functions
|
|
export function deleteOldSermons(retentionDays: number) {
|
|
const db = getDatabase()
|
|
// Calculate the cutoff date based on retention days
|
|
const cutoffDate = new Date()
|
|
cutoffDate.setDate(cutoffDate.getDate() - retentionDays)
|
|
const cutoffDateStr = cutoffDate.toISOString().split('T')[0] // Format as YYYY-MM-DD
|
|
|
|
// Delete sermons older than the cutoff date
|
|
return db.prepare('DELETE FROM sermons WHERE date < ?').run(cutoffDateStr)
|
|
}
|