perf: Comprehensive efficiency optimizations

Implemented all 5 critical efficiency improvements to optimize
performance, reduce resource usage, and improve scalability.

## 1. Database Indexes
- Added indexes on sermon_notes foreign keys (user_id, sermon_id)
- Added composite index on sermons (archived, date DESC)
- Added indexes on frequently queried columns across all tables
- Impact: Faster queries as data grows, better JOIN performance

## 2. Eliminated N+1 Query Pattern
- Reduced 2 API calls to 1 on home page load
- Changed from separate active/archived fetches to single call
- Filter archived sermons client-side using computed properties
- Impact: 50% reduction in HTTP requests per page load

## 3. Scheduled Database Cleanup
- Extended existing plugin to clean expired sessions hourly
- Added cleanup for expired rate limits every hour
- Added cleanup for expired password reset codes every hour
- Sermon cleanup continues to run daily based on retention policy
- Impact: Prevents database table growth, better performance

## 4. Multi-stage Docker Build
- Implemented 3-stage build: deps -> builder -> runtime
- Separated build-time and runtime dependencies
- Added non-root user (nuxt:nodejs) for security
- Integrated dumb-init for proper signal handling
- Added health check endpoint at /api/health
- Impact: Smaller image size, faster deployments, better security

## 5. HTTP Caching
- Static assets: 1 year cache (immutable)
- Logos/images: 1 year cache (immutable)
- API routes: No cache (always fresh)
- HTML pages: 10 minute cache with revalidation
- Impact: Reduced bandwidth, faster page loads, less server load

All optimizations follow best practices and maintain backward
compatibility with existing functionality.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-06 08:01:45 -05:00
parent 0126b7e835
commit 287284c2fe
6 changed files with 180 additions and 37 deletions

View File

@@ -1,20 +1,30 @@
FROM node:20-alpine
# ======================
# Stage 1: Dependencies
# ======================
FROM node:20-alpine AS deps
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Install dependencies (including devDependencies for build)
RUN npm ci
# Copy application files
# ======================
# Stage 2: Builder
# ======================
FROM node:20-alpine AS builder
WORKDIR /app
# Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
# Copy application source
COPY . .
# Create data directory for SQLite database
RUN mkdir -p /app/data
# Accept build arguments
# Accept build arguments for Nuxt build-time configuration
ARG SITE_URL=https://church.example.com
ARG EMAIL_HOST=smtp.example.com
ARG EMAIL_PORT=587
@@ -30,13 +40,37 @@ ENV EMAIL_USER=$EMAIL_USER
ENV EMAIL_PASSWORD=$EMAIL_PASSWORD
ENV EMAIL_FROM=$EMAIL_FROM
# Security: AUTH_SECRET and admin credentials are auto-generated on first launch
# They are stored in the database and logged once to container logs
# Use: docker logs <container-name> | grep "ADMIN CREDENTIALS" to retrieve them
# Build the application
RUN npm run build
# Remove development dependencies after build
RUN npm prune --production
# ======================
# Stage 3: Runtime
# ======================
FROM node:20-alpine AS runtime
WORKDIR /app
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
adduser -S nuxt -u 1001
# Copy only production dependencies and built output
COPY --from=builder --chown=nuxt:nodejs /app/.output ./.output
COPY --from=builder --chown=nuxt:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nuxt:nodejs /app/package.json ./package.json
# Create data directory for SQLite database with proper permissions
RUN mkdir -p /app/data && chown -R nuxt:nodejs /app/data
# Switch to non-root user
USER nuxt
# Expose port
EXPOSE 3000
@@ -45,5 +79,14 @@ ENV NODE_ENV=production
ENV NUXT_HOST=0.0.0.0
ENV NUXT_PORT=3000
# Start the application
# Security: AUTH_SECRET and admin credentials are auto-generated on first launch
# They are stored in the database and logged once to container logs
# Use: docker logs <container-name> | grep "ADMIN CREDENTIALS" to retrieve them
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" || exit 1
# Use dumb-init to properly handle signals
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", ".output/server/index.mjs"]

View File

@@ -26,6 +26,19 @@ export default defineNuxtConfig({
css: ['~/assets/css/main.css'],
// Configure route caching rules
routeRules: {
// Static assets - cache for 1 year (immutable)
'/_nuxt/**': { headers: { 'cache-control': 'public, max-age=31536000, immutable' } },
'/logos/**': { headers: { 'cache-control': 'public, max-age=31536000, immutable' } },
// API routes - no cache
'/api/**': { headers: { 'cache-control': 'no-store, no-cache, must-revalidate' } },
// HTML pages - cache for 10 minutes with revalidation
'/**': { headers: { 'cache-control': 'public, max-age=600, must-revalidate' } }
},
runtimeConfig: {
// Private runtime config (server-side only)
// These are automatically overridden by NUXT_<KEY> environment variables

View File

@@ -200,17 +200,20 @@ const greeting = computed(() => {
return `${timeGreeting}, ${firstName.value}!`
})
// Fetch non-archived sermons for the most recent sermon
const { data: activeSermons } = await useFetch('/api/sermons?includeArchived=false')
// Fetch archived sermons for the previous sermons dropdown
const { data: archivedSermons } = await useFetch('/api/sermons?includeArchived=true')
// Fetch all sermons in a single request (more efficient than two separate calls)
const { data: allSermons } = await useFetch('/api/sermons?includeArchived=true')
const selectedSermonSlug = ref('')
// Filter active (non-archived) sermons client-side
const activeSermons = computed(() => {
if (!allSermons.value) return []
return allSermons.value.filter((s: any) => s.archived !== 1)
})
// Get today's sermon (if one exists for today's date)
const todaysSermon = computed(() => {
if (!activeSermons.value || activeSermons.value.length === 0) return null
if (activeSermons.value.length === 0) return null
const today = new Date()
today.setHours(0, 0, 0, 0)
@@ -247,7 +250,7 @@ const todaysSermon = computed(() => {
// Get upcoming sermons (future dates)
const upcomingSermons = computed(() => {
if (!activeSermons.value) return []
if (activeSermons.value.length === 0) return []
const today = new Date()
today.setHours(0, 0, 0, 0)
@@ -289,8 +292,8 @@ const upcomingSermons = computed(() => {
// Get archived sermons only for the previous sermons dropdown
const previousSermons = computed(() => {
if (!archivedSermons.value) return []
return archivedSermons.value.filter((s: any) => s.archived === 1)
if (!allSermons.value) return []
return allSermons.value.filter((s: any) => s.archived === 1)
})
// Check if there are any current or upcoming sermons

6
server/api/health.get.ts Normal file
View File

@@ -0,0 +1,6 @@
export default defineEventHandler(() => {
return {
status: 'healthy',
timestamp: new Date().toISOString()
}
})

View File

@@ -1,4 +1,4 @@
import { getSetting, deleteOldSermons } from '../utils/database'
import { getSetting, deleteOldSermons, deleteExpiredSessions, getDatabase } from '../utils/database'
// Map retention policy to days
const retentionDaysMap: Record<string, number> = {
@@ -12,7 +12,7 @@ const retentionDaysMap: Record<string, number> = {
'10_years': 3650
}
async function runCleanup() {
async function runSermonCleanup() {
try {
// Get the retention policy setting
const setting = getSetting('sermon_retention_policy')
@@ -34,23 +34,76 @@ async function runCleanup() {
console.log(`[Sermon Cleanup] No sermons to delete (policy: ${retentionPolicy})`)
}
} catch (error) {
console.error('[Sermon Cleanup] Error running cleanup:', error)
console.error('[Sermon Cleanup] Error running sermon cleanup:', error)
}
}
export default defineNitroPlugin((nitroApp) => {
console.log('[Sermon Cleanup] Scheduling daily cleanup task')
async function runSessionCleanup() {
try {
const result = deleteExpiredSessions()
if (result.changes > 0) {
console.log(`[Session Cleanup] Deleted ${result.changes} expired session(s)`)
}
} catch (error) {
console.error('[Session Cleanup] Error cleaning expired sessions:', error)
}
}
// Run cleanup once at startup (after a short delay)
async function runRateLimitCleanup() {
try {
const db = getDatabase()
const result = db.prepare("DELETE FROM rate_limits WHERE reset_at <= datetime('now')").run()
if (result.changes > 0) {
console.log(`[Rate Limit Cleanup] Deleted ${result.changes} expired rate limit(s)`)
}
} catch (error) {
console.error('[Rate Limit Cleanup] Error cleaning expired rate limits:', error)
}
}
async function runPasswordResetCleanup() {
try {
const db = getDatabase()
const result = db.prepare("DELETE FROM password_reset_codes WHERE expires_at <= datetime('now')").run()
if (result.changes > 0) {
console.log(`[Password Reset Cleanup] Deleted ${result.changes} expired reset code(s)`)
}
} catch (error) {
console.error('[Password Reset Cleanup] Error cleaning expired password reset codes:', error)
}
}
async function runAllCleanupTasks() {
console.log('[Cleanup] Starting scheduled cleanup tasks')
await runSermonCleanup()
await runSessionCleanup()
await runRateLimitCleanup()
await runPasswordResetCleanup()
console.log('[Cleanup] All cleanup tasks completed')
}
export default defineNitroPlugin((nitroApp) => {
console.log('[Cleanup Plugin] Initializing scheduled cleanup tasks')
// Run all cleanup tasks at startup (after a short delay)
setTimeout(async () => {
console.log('[Sermon Cleanup] Running initial cleanup')
await runCleanup()
console.log('[Cleanup] Running initial cleanup')
await runAllCleanupTasks()
}, 10000) // 10 seconds after startup
// Schedule cleanup to run daily (every 24 hours)
// Schedule cleanup to run daily (every 24 hours) for sermons
const dailyInterval = 24 * 60 * 60 * 1000 // 24 hours in milliseconds
setInterval(async () => {
console.log('[Sermon Cleanup] Running scheduled cleanup')
await runCleanup()
await runAllCleanupTasks()
}, dailyInterval)
// Run session/rate-limit cleanup more frequently (every hour)
// This prevents these tables from growing too large
const hourlyInterval = 60 * 60 * 1000 // 1 hour in milliseconds
setInterval(async () => {
console.log('[Cleanup] Running hourly cleanup')
await runSessionCleanup()
await runRateLimitCleanup()
await runPasswordResetCleanup()
}, hourlyInterval)
})

View File

@@ -279,6 +279,11 @@ export function getDatabase() {
)
`)
// Create indexes for foreign keys to optimize queries
// SQLite doesn't automatically index foreign keys
db.exec(`CREATE INDEX IF NOT EXISTS idx_sermon_notes_user_id ON sermon_notes(user_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_sermon_notes_sermon_id ON sermon_notes(sermon_id)`)
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -317,6 +322,26 @@ export function getDatabase() {
)
`)
// Create additional performance indexes for frequently queried columns
// Sermons: date is used for sorting and filtering
db.exec(`CREATE INDEX IF NOT EXISTS idx_sermons_date ON sermons(date DESC)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_sermons_archived ON sermons(archived, date DESC)`)
// Users: email lookups for password reset
db.exec(`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`)
// Sessions: token lookups for authentication
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)`)
// Rate limits: identifier+endpoint lookups
db.exec(`CREATE INDEX IF NOT EXISTS idx_rate_limits_identifier_endpoint ON rate_limits(identifier, endpoint)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_rate_limits_reset_at ON rate_limits(reset_at)`)
// Password reset codes: email+code lookups
db.exec(`CREATE INDEX IF NOT EXISTS idx_password_reset_codes_email ON password_reset_codes(email)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_password_reset_codes_expires_at ON password_reset_codes(expires_at)`)
// Initialize AUTH_SECRET (generate if needed)
initializeAuthSecret()