- Add daily-verse API endpoint with 7 rotating verses in Romanian and English - Replace static homepage verse with dynamic fetch from API - Ensure consistent daily rotation using day-of-year calculation - Support both ro and en locales for verse content 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
37 KiB
Biblical Guide Web App - Granular Implementation Plan
Primary UI Language: Romanian Secondary UI Language: English
Project Overview
A self-hosted Bible study web application with AI chat capabilities, real-time features, and comprehensive user management using PostgreSQL with extensions for all functionality.
Technology Stack
Core Technologies
- Frontend: Next.js 14 (App Router) + TypeScript
- Backend: Next.js API Routes + WebSocket Server
- Database: PostgreSQL 16 with extensions (pgvector, pg_cron, pg_jwt)
- Cache: PostgreSQL with UNLOGGED tables for caching
- Real-time: Socket.io for WebSocket connections
- AI/LLM: Azure OpenAI API + Ollama API
- Containerization: Docker + Docker Compose
- Testing: Jest + React Testing Library
- Styling: Tailwind CSS + shadcn/ui components
Project Structure
├── docker/
│ ├── Dockerfile.dev
│ ├── Dockerfile.prod
│ └── nginx/
│ └── nginx.conf
├── docker-compose.yml
├── docker-compose.prod.yml
├── src/
│ ├── app/
│ │ ├── (auth)/
│ │ ├── (main)/
│ │ ├── api/
│ │ └── layout.tsx
│ ├── components/
│ ├── lib/
│ ├── hooks/
│ ├── types/
│ ├── utils/
│ └── middleware.ts
├── prisma/
│ ├── schema.prisma
│ ├── migrations/
│ └── seed/
├── scripts/
│ ├── import-bible.ts
│ └── setup-db.sh
├── tests/
├── public/
└── config/
Phase 1: Foundation Setup (Days 1-3)
Step 1.1: Initialize Project and Docker Environment
# Create project directory
mkdir bible-chat-app && cd bible-chat-app
# Initialize Next.js with TypeScript
npx create-next-app@latest . --typescript --tailwind --app --no-src-dir
# Create Docker configuration
Docker Compose Configuration (docker-compose.yml):
version: '3.8'
services:
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_DB: bible_chat
POSTGRES_USER: bible_admin
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5432:5432"
app:
build:
context: .
dockerfile: docker/Dockerfile.dev
volumes:
- .:/app
- /app/node_modules
ports:
- "3000:3000"
- "3001:3001" # WebSocket port
depends_on:
- postgres
environment:
DATABASE_URL: postgresql://bible_admin:${DB_PASSWORD}@postgres:5432/bible_chat
AZURE_OPENAI_KEY: ${AZURE_OPENAI_KEY}
OLLAMA_API_URL: ${OLLAMA_API_URL}
Step 1.2: Database Schema Setup
Install Dependencies:
npm install @prisma/client prisma @types/node
npm install -D @types/bcryptjs bcryptjs
Prisma Schema (prisma/schema.prisma):
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
email String @unique
passwordHash String
name String?
theme String @default("light")
fontSize String @default("medium")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastLoginAt DateTime?
sessions Session[]
bookmarks Bookmark[]
notes Note[]
chatMessages ChatMessage[]
prayerRequests PrayerRequest[]
readingHistory ReadingHistory[]
preferences UserPreference[]
}
model Session {
id String @id @default(uuid())
userId String
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@index([userId])
@@index([token])
}
model BibleBook {
id Int @id
name String
testament String
orderNum Int
chapters BibleChapter[]
@@index([testament])
}
model BibleChapter {
id String @id @default(uuid())
bookId Int
chapterNum Int
verses BibleVerse[]
book BibleBook @relation(fields: [bookId], references: [id])
@@unique([bookId, chapterNum])
@@index([bookId])
}
model BibleVerse {
id String @id @default(uuid())
chapterId String
verseNum Int
text String @db.Text
version String @default("KJV")
chapter BibleChapter @relation(fields: [chapterId], references: [id])
bookmarks Bookmark[]
notes Note[]
@@unique([chapterId, verseNum, version])
@@index([chapterId])
@@index([version])
}
model ChatMessage {
id String @id @default(uuid())
userId String
role String // 'user' or 'assistant'
content String @db.Text
metadata Json? // Store verse references, etc.
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@index([userId, createdAt])
}
model Bookmark {
id String @id @default(uuid())
userId String
verseId String
note String?
color String @default("#FFD700")
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
verse BibleVerse @relation(fields: [verseId], references: [id])
@@unique([userId, verseId])
@@index([userId])
}
model PrayerRequest {
id String @id @default(uuid())
userId String?
content String @db.Text
isAnonymous Boolean @default(true)
prayerCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id])
prayers Prayer[]
@@index([createdAt])
}
model Prayer {
id String @id @default(uuid())
requestId String
ipAddress String // For anonymous prayer counting
createdAt DateTime @default(now())
request PrayerRequest @relation(fields: [requestId], references: [id])
@@unique([requestId, ipAddress])
}
Step 1.3: Database Extensions and Functions
Database Initialization Script (scripts/init.sql):
-- Enable required extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
CREATE EXTENSION IF NOT EXISTS "vector";
-- Create cache table for Bible verses
CREATE UNLOGGED TABLE verse_cache (
key VARCHAR(255) PRIMARY KEY,
value TEXT,
expires_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
-- Create index for full-text search
CREATE INDEX verse_text_gin_idx ON "BibleVerse" USING gin(to_tsvector('english', text));
-- Function for verse search
CREATE OR REPLACE FUNCTION search_verses(search_query TEXT, limit_count INT DEFAULT 10)
RETURNS TABLE(
verse_id UUID,
book_name TEXT,
chapter_num INT,
verse_num INT,
verse_text TEXT,
rank REAL
) AS $$
BEGIN
RETURN QUERY
SELECT
v.id,
b.name,
c."chapterNum",
v."verseNum",
v.text,
ts_rank(to_tsvector('english', v.text), plainto_tsquery('english', search_query)) as rank
FROM "BibleVerse" v
JOIN "BibleChapter" c ON v."chapterId" = c.id
JOIN "BibleBook" b ON c."bookId" = b.id
WHERE to_tsvector('english', v.text) @@ plainto_tsquery('english', search_query)
ORDER BY rank DESC
LIMIT limit_count;
END;
$$ LANGUAGE plpgsql;
-- Session cleanup function
CREATE OR REPLACE FUNCTION cleanup_expired_sessions()
RETURNS void AS $$
BEGIN
DELETE FROM "Session" WHERE "expiresAt" < NOW();
END;
$$ LANGUAGE plpgsql;
Phase 2: Core Backend Implementation (Days 4-8)
Step 2.1: Environment Configuration
.env.local file:
DATABASE_URL=postgresql://bible_admin:password@localhost:5432/bible_chat
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=generate-random-secret-here
AZURE_OPENAI_KEY=your-azure-key
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com
AZURE_OPENAI_DEPLOYMENT=gpt-4
OLLAMA_API_URL=http://your-ollama-server:11434
JWT_SECRET=your-jwt-secret
REDIS_URL=redis://localhost:6379
Step 2.2: Database Connection and Utilities
src/lib/db.ts:
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
Step 2.3: Authentication System
src/lib/auth/index.ts:
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { prisma } from '@/lib/db'
export async function createUser(email: string, password: string, name?: string) {
const passwordHash = await bcrypt.hash(password, 10)
return prisma.user.create({
data: { email, passwordHash, name }
})
}
export async function validateUser(email: string, password: string) {
const user = await prisma.user.findUnique({ where: { email } })
if (!user) return null
const isValid = await bcrypt.compare(password, user.passwordHash)
return isValid ? user : null
}
export function generateToken(userId: string): string {
return jwt.sign({ userId }, process.env.JWT_SECRET!, { expiresIn: '7d' })
}
Step 2.4: Bible Data Import Script
scripts/import-bible.ts:
import { PrismaClient } from '@prisma/client'
import fs from 'fs/promises'
import path from 'path'
const prisma = new PrismaClient()
interface BibleData {
books: Array<{
id: number
name: string
testament: string
chapters: Array<{
number: number
verses: Array<{
number: number
text: string
}>
}>
}>
}
async function importBible() {
const dataPath = path.join(process.cwd(), 'data', 'bible-kjv.json')
const bibleData: BibleData = JSON.parse(await fs.readFile(dataPath, 'utf-8'))
for (const book of bibleData.books) {
// Create book
await prisma.bibleBook.create({
data: {
id: book.id,
name: book.name,
testament: book.testament,
orderNum: book.id
}
})
// Create chapters and verses
for (const chapter of book.chapters) {
const createdChapter = await prisma.bibleChapter.create({
data: {
bookId: book.id,
chapterNum: chapter.number
}
})
// Bulk create verses
await prisma.bibleVerse.createMany({
data: chapter.verses.map(verse => ({
chapterId: createdChapter.id,
verseNum: verse.number,
text: verse.text,
version: 'KJV'
}))
})
}
}
}
importBible()
.then(() => console.log('Bible import completed'))
.catch(console.error)
.finally(() => prisma.$disconnect())
Phase 3: API Routes Implementation (Days 9-12)
Step 3.1: Authentication API Routes
src/app/api/auth/register/route.ts:
import { NextResponse } from 'next/server'
import { createUser, generateToken } from '@/lib/auth'
import { prisma } from '@/lib/db'
export async function POST(request: Request) {
try {
const { email, password, name } = await request.json()
// Validation
if (!email || !password) {
return NextResponse.json({ error: 'Email and password required' }, { status: 400 })
}
// Check if user exists
const existing = await prisma.user.findUnique({ where: { email } })
if (existing) {
return NextResponse.json({ error: 'User already exists' }, { status: 409 })
}
// Create user
const user = await createUser(email, password, name)
const token = generateToken(user.id)
// Create session
await prisma.session.create({
data: {
userId: user.id,
token,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
}
})
return NextResponse.json({ user: { id: user.id, email: user.email, name: user.name }, token })
} catch (error) {
return NextResponse.json({ error: 'Server error' }, { status: 500 })
}
}
Step 3.2: Bible API Routes
src/app/api/bible/search/route.ts:
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const query = searchParams.get('q')
const limit = parseInt(searchParams.get('limit') || '10')
if (!query) {
return NextResponse.json({ error: 'Query required' }, { status: 400 })
}
// Use raw SQL for full-text search
const results = await prisma.$queryRaw`
SELECT * FROM search_verses(${query}, ${limit})
`
return NextResponse.json({ results })
}
Step 3.3: AI Chat API Integration
src/lib/ai/azure-openai.ts:
import { AzureOpenAI } from 'openai'
const client = new AzureOpenAI({
apiKey: process.env.AZURE_OPENAI_KEY!,
apiVersion: '2024-02-01',
endpoint: process.env.AZURE_OPENAI_ENDPOINT!,
})
export async function generateChatResponse(
messages: Array<{ role: string; content: string }>,
verseContext?: string
) {
const systemPrompt = `You are a helpful Bible study assistant. Always provide scripture references for your answers. ${verseContext ? `Context: ${verseContext}` : ''}`
const response = await client.chat.completions.create({
model: process.env.AZURE_OPENAI_DEPLOYMENT!,
messages: [
{ role: 'system', content: systemPrompt },
...messages
],
temperature: 0.7,
max_tokens: 1000
})
return response.choices[0].message.content
}
src/lib/ai/embeddings.ts:
export async function generateEmbedding(text: string): Promise<number[]> {
const response = await fetch(`${process.env.OLLAMA_API_URL}/api/embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'nomic-embed-text',
prompt: text
})
})
const data = await response.json()
return data.embedding
}
Step 3.4: WebSocket Server for Real-time Features
src/lib/websocket/server.ts:
import { Server } from 'socket.io'
import { createServer } from 'http'
import { parse } from 'url'
import next from 'next'
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()
app.prepare().then(() => {
const server = createServer((req, res) => {
const parsedUrl = parse(req.url!, true)
handle(req, res, parsedUrl)
})
const io = new Server(server, {
cors: {
origin: process.env.NEXTAUTH_URL,
methods: ['GET', 'POST']
}
})
io.on('connection', (socket) => {
console.log('Client connected:', socket.id)
// Join prayer room
socket.on('join-prayer-room', () => {
socket.join('prayers')
})
// Handle new prayer
socket.on('new-prayer', async (data) => {
// Save to database
// Broadcast to all in prayer room
io.to('prayers').emit('prayer-added', data)
})
// Handle prayer count update
socket.on('pray-for', async (requestId) => {
// Update count in database
// Broadcast updated count
io.to('prayers').emit('prayer-count-updated', { requestId, count: newCount })
})
socket.on('disconnect', () => {
console.log('Client disconnected:', socket.id)
})
})
server.listen(3001, () => {
console.log('WebSocket server running on port 3001')
})
})
Phase 4: Frontend Implementation (Days 13-18)
Primary UI Language: Romanian Secondary UI Language: English
Step 4.1: Component Library Setup
Install UI Dependencies:
npm install @radix-ui/react-dropdown-menu @radix-ui/react-dialog
npm install @radix-ui/react-tabs @radix-ui/react-toast
npm install socket.io-client zustand
npm install react-markdown remark-gfm
Step 4.2: Global State Management
src/lib/store/index.ts:
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface AppState {
user: User | null
theme: 'light' | 'dark'
fontSize: 'small' | 'medium' | 'large'
currentBook: number
currentChapter: number
bookmarks: Bookmark[]
setUser: (user: User | null) => void
setTheme: (theme: 'light' | 'dark') => void
setFontSize: (size: 'small' | 'medium' | 'large') => void
}
export const useStore = create<AppState>()(
persist(
(set) => ({
user: null,
theme: 'light',
fontSize: 'medium',
currentBook: 1,
currentChapter: 1,
bookmarks: [],
setUser: (user) => set({ user }),
setTheme: (theme) => set({ theme }),
setFontSize: (fontSize) => set({ fontSize }),
}),
{
name: 'bible-chat-storage',
}
)
)
Step 4.3: Main Layout Component
src/app/layout.tsx:
import './globals.css'
import { Providers } from '@/components/providers'
import { Navigation } from '@/components/navigation'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<Providers>
<Navigation />
<main className="container mx-auto px-4 py-8">
{children}
</main>
</Providers>
</body>
</html>
)
}
Step 4.4: Bible Reader Component
src/components/bible/reader.tsx:
'use client'
import { useState, useEffect } from 'react'
import { useStore } from '@/lib/store'
export function BibleReader() {
const { currentBook, currentChapter } = useStore()
const [verses, setVerses] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchChapter(currentBook, currentChapter)
}, [currentBook, currentChapter])
async function fetchChapter(bookId: number, chapterNum: number) {
setLoading(true)
const res = await fetch(`/api/bible/chapter?book=${bookId}&chapter=${chapterNum}`)
const data = await res.json()
setVerses(data.verses)
setLoading(false)
}
const handleVerseClick = async (verseId: string) => {
// Add to bookmarks
await fetch('/api/bookmarks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ verseId })
})
}
if (loading) return <div>Loading...</div>
return (
<div className="prose prose-lg max-w-none">
{verses.map((verse: any) => (
<span
key={verse.id}
className="verse hover:bg-yellow-100 cursor-pointer"
onClick={() => handleVerseClick(verse.id)}
>
<sup className="text-xs mr-1">{verse.verseNum}</sup>
{verse.text}
</span>
))}
</div>
)
}
Step 4.5: AI Chat Interface
src/components/chat/chat-interface.tsx:
'use client'
import { useState, useRef, useEffect } from 'react'
import { Send } from 'lucide-react'
export function ChatInterface() {
const [messages, setMessages] = useState([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const messagesEndRef = useRef(null)
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}
useEffect(scrollToBottom, [messages])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!input.trim() || loading) return
const userMessage = { role: 'user', content: input }
setMessages(prev => [...prev, userMessage])
setInput('')
setLoading(true)
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [...messages, userMessage]
})
})
const data = await res.json()
setMessages(prev => [...prev, {
role: 'assistant',
content: data.response
}])
} catch (error) {
console.error('Chat error:', error)
} finally {
setLoading(false)
}
}
return (
<div className="flex flex-col h-[600px] border rounded-lg">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg, idx) => (
<div
key={idx}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[70%] p-3 rounded-lg ${
msg.role === 'user'
? 'bg-blue-500 text-white'
: 'bg-gray-100'
}`}
>
{msg.content}
</div>
</div>
))}
{loading && (
<div className="flex justify-start">
<div className="bg-gray-100 p-3 rounded-lg">
<div className="flex space-x-2">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-100" />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-200" />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<form onSubmit={handleSubmit} className="p-4 border-t">
<div className="flex space-x-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask about the Bible..."
className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2"
disabled={loading}
/>
<button
type="submit"
disabled={loading || !input.trim()}
className="px-4 py-2 bg-blue-500 text-white rounded-lg disabled:opacity-50"
>
<Send className="w-5 h-5" />
</button>
</div>
</form>
</div>
)
}
Step 4.6: Real-time Prayer Wall
src/components/prayer/prayer-wall.tsx:
'use client'
import { useEffect, useState } from 'react'
import { io, Socket } from 'socket.io-client'
export function PrayerWall() {
const [socket, setSocket] = useState<Socket | null>(null)
const [prayers, setPrayers] = useState([])
const [newPrayer, setNewPrayer] = useState('')
useEffect(() => {
const socketInstance = io('http://localhost:3001')
setSocket(socketInstance)
socketInstance.emit('join-prayer-room')
socketInstance.on('prayer-added', (prayer) => {
setPrayers(prev => [prayer, ...prev])
})
socketInstance.on('prayer-count-updated', ({ requestId, count }) => {
setPrayers(prev => prev.map(p =>
p.id === requestId ? { ...p, prayerCount: count } : p
))
})
// Load initial prayers
fetchPrayers()
return () => {
socketInstance.disconnect()
}
}, [])
const fetchPrayers = async () => {
const res = await fetch('/api/prayers')
const data = await res.json()
setPrayers(data.prayers)
}
const handleSubmitPrayer = async (e: React.FormEvent) => {
e.preventDefault()
if (!newPrayer.trim()) return
const res = await fetch('/api/prayers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newPrayer, isAnonymous: true })
})
const prayer = await res.json()
socket?.emit('new-prayer', prayer)
setNewPrayer('')
}
const handlePrayFor = (requestId: string) => {
socket?.emit('pray-for', requestId)
}
return (
<div className="space-y-6">
<form onSubmit={handleSubmitPrayer} className="space-y-4">
<textarea
value={newPrayer}
onChange={(e) => setNewPrayer(e.target.value)}
placeholder="Share your prayer request..."
className="w-full p-3 border rounded-lg resize-none h-24"
/>
<button
type="submit"
className="px-6 py-2 bg-purple-600 text-white rounded-lg"
>
Submit Prayer Request
</button>
</form>
<div className="space-y-4">
{prayers.map((prayer: any) => (
<div key={prayer.id} className="p-4 bg-white rounded-lg shadow">
<p className="mb-3">{prayer.content}</p>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">
{new Date(prayer.createdAt).toLocaleDateString()}
</span>
<button
onClick={() => handlePrayFor(prayer.id)}
className="flex items-center space-x-2 px-4 py-2 bg-purple-100 rounded-lg hover:bg-purple-200"
>
<span>🙏</span>
<span>{prayer.prayerCount} prayers</span>
</button>
</div>
</div>
))}
</div>
</div>
)
}
Phase 5: Testing Implementation (Days 19-20)
Step 5.1: Unit Tests Setup
jest.config.js:
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
testMatch: ['**/__tests__/**/*.test.ts', '**/__tests__/**/*.test.tsx'],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
],
}
Step 5.2: API Route Tests
tests/api/auth.test.ts:
import { createMocks } from 'node-mocks-http'
import { POST as registerHandler } from '@/app/api/auth/register/route'
describe('/api/auth/register', () => {
test('creates new user successfully', async () => {
const { req, res } = createMocks({
method: 'POST',
body: {
email: 'test@example.com',
password: 'SecurePass123!',
name: 'Test User'
},
})
await registerHandler(req as any)
expect(res._getStatusCode()).toBe(200)
const json = JSON.parse(res._getData())
expect(json).toHaveProperty('token')
expect(json.user.email).toBe('test@example.com')
})
test('rejects duplicate email', async () => {
// First registration
const { req: req1 } = createMocks({
method: 'POST',
body: {
email: 'duplicate@example.com',
password: 'Pass123!',
},
})
await registerHandler(req1 as any)
// Duplicate attempt
const { req: req2, res: res2 } = createMocks({
method: 'POST',
body: {
email: 'duplicate@example.com',
password: 'Pass123!',
},
})
await registerHandler(req2 as any)
expect(res2._getStatusCode()).toBe(409)
})
})
Step 5.3: Component Tests
tests/components/bible-reader.test.tsx:
import { render, screen, waitFor } from '@testing-library/react'
import { BibleReader } from '@/components/bible/reader'
jest.mock('@/lib/store', () => ({
useStore: () => ({
currentBook: 1,
currentChapter: 1,
}),
}))
describe('BibleReader', () => {
test('renders verses correctly', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({
verses: [
{ id: '1', verseNum: 1, text: 'In the beginning...' },
{ id: '2', verseNum: 2, text: 'And the earth was...' },
],
}),
})
) as jest.Mock
render(<BibleReader />)
await waitFor(() => {
expect(screen.getByText(/In the beginning/)).toBeInTheDocument()
})
})
})
Phase 6: Deployment Configuration (Days 21-22)
Step 6.1: Production Docker Configuration
docker/Dockerfile.prod:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]
Step 6.2: Nginx Configuration
docker/nginx/nginx.conf:
upstream app {
server app:3000;
}
upstream websocket {
server app:3001;
}
server {
listen 80;
server_name _;
client_max_body_size 10M;
location / {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /socket.io/ {
proxy_pass http://websocket;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Step 6.3: Production Docker Compose
docker-compose.prod.yml:
version: '3.8'
services:
postgres:
image: pgvector/pgvector:pg16
restart: always
environment:
POSTGRES_DB: bible_chat
POSTGRES_USER: bible_admin
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- bible_network
app:
build:
context: .
dockerfile: docker/Dockerfile.prod
restart: always
environment:
DATABASE_URL: postgresql://bible_admin:${DB_PASSWORD}@postgres:5432/bible_chat
AZURE_OPENAI_KEY: ${AZURE_OPENAI_KEY}
AZURE_OPENAI_ENDPOINT: ${AZURE_OPENAI_ENDPOINT}
AZURE_OPENAI_DEPLOYMENT: ${AZURE_OPENAI_DEPLOYMENT}
OLLAMA_API_URL: ${OLLAMA_API_URL}
JWT_SECRET: ${JWT_SECRET}
NEXTAUTH_URL: ${NEXTAUTH_URL}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
depends_on:
- postgres
networks:
- bible_network
nginx:
image: nginx:alpine
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./docker/nginx/nginx.conf:/etc/nginx/conf.d/default.conf
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
depends_on:
- app
networks:
- bible_network
networks:
bible_network:
driver: bridge
volumes:
postgres_data:
Phase 7: Performance Optimization (Days 23-24)
Step 7.1: Database Optimization
scripts/optimize-db.sql:
-- Create materialized view for popular verses
CREATE MATERIALIZED VIEW popular_verses AS
SELECT
v.id,
v.text,
b.name as book_name,
c."chapterNum",
v."verseNum",
COUNT(bm.id) as bookmark_count
FROM "BibleVerse" v
JOIN "BibleChapter" c ON v."chapterId" = c.id
JOIN "BibleBook" b ON c."bookId" = b.id
LEFT JOIN "Bookmark" bm ON v.id = bm."verseId"
GROUP BY v.id, v.text, b.name, c."chapterNum", v."verseNum"
ORDER BY bookmark_count DESC
LIMIT 100;
-- Refresh materialized view periodically
CREATE OR REPLACE FUNCTION refresh_popular_verses()
RETURNS void AS $$
BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY popular_verses;
END;
$$ LANGUAGE plpgsql;
-- Create indexes for performance
CREATE INDEX idx_chat_messages_user_created ON "ChatMessage"("userId", "createdAt" DESC);
CREATE INDEX idx_bookmarks_user ON "Bookmark"("userId");
CREATE INDEX idx_reading_history_user ON "ReadingHistory"("userId", "viewedAt" DESC);
-- Partition chat messages by month for better performance
CREATE TABLE "ChatMessage_2024_01" PARTITION OF "ChatMessage"
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
Step 7.2: Caching Layer
src/lib/cache/index.ts:
import { prisma } from '@/lib/db'
export class CacheManager {
static async get(key: string): Promise<string | null> {
const result = await prisma.$queryRaw<{ value: string }[]>`
SELECT value FROM verse_cache
WHERE key = ${key}
AND expires_at > NOW()
LIMIT 1
`
return result[0]?.value || null
}
static async set(key: string, value: string, ttl: number = 3600) {
const expiresAt = new Date(Date.now() + ttl * 1000)
await prisma.$executeRaw`
INSERT INTO verse_cache (key, value, expires_at)
VALUES (${key}, ${value}, ${expiresAt})
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value, expires_at = EXCLUDED.expires_at
`
}
static async invalidate(pattern: string) {
await prisma.$executeRaw`
DELETE FROM verse_cache WHERE key LIKE ${pattern}
`
}
}
Phase 8: Security Implementation (Day 25)
Step 8.1: Security Middleware
src/middleware.ts:
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { verifyToken } from '@/lib/auth'
export async function middleware(request: NextRequest) {
// Rate limiting
const ip = request.ip || 'unknown'
const rateLimit = await checkRateLimit(ip)
if (!rateLimit.allowed) {
return new NextResponse('Too Many Requests', { status: 429 })
}
// Protected routes
if (request.nextUrl.pathname.startsWith('/api/protected')) {
const token = request.headers.get('authorization')?.replace('Bearer ', '')
if (!token) {
return new NextResponse('Unauthorized', { status: 401 })
}
try {
const payload = await verifyToken(token)
// Add user to headers
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-user-id', payload.userId)
return NextResponse.next({
request: {
headers: requestHeaders,
},
})
} catch (error) {
return new NextResponse('Invalid token', { status: 401 })
}
}
return NextResponse.next()
}
export const config = {
matcher: ['/api/:path*'],
}
async function checkRateLimit(ip: string): Promise<{ allowed: boolean }> {
// Implement rate limiting logic using database
// Store request counts in database with TTL
return { allowed: true }
}
Step 8.2: Input Validation
src/lib/validation/index.ts:
import { z } from 'zod'
export const userSchema = z.object({
email: z.string().email(),
password: z.string().min(8).regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/),
name: z.string().min(2).max(50).optional(),
})
export const chatMessageSchema = z.object({
content: z.string().min(1).max(1000),
verseContext: z.string().optional(),
})
export const prayerRequestSchema = z.object({
content: z.string().min(10).max(500),
isAnonymous: z.boolean().default(true),
})
Deployment Instructions
Step 1: Server Setup
# Update system
sudo apt update && sudo apt upgrade -y
# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
# Install Docker Compose
sudo apt install docker-compose -y
# Setup firewall
sudo ufw allow 22
sudo ufw allow 80
sudo ufw allow 443
sudo ufw enable
Step 2: Deploy Application
# Clone repository
git clone https://github.com/yourusername/bible-chat-app.git
cd bible-chat-app
# Create .env.production file
cp .env.example .env.production
# Edit with your production values
# Build and start
docker-compose -f docker-compose.prod.yml up -d
# Run database migrations
docker-compose -f docker-compose.prod.yml exec app npx prisma migrate deploy
# Import Bible data
docker-compose -f docker-compose.prod.yml exec app npm run import-bible
Step 3: SSL Setup (Optional)
# Install Certbot
sudo apt install certbot python3-certbot-nginx
# Get SSL certificate
sudo certbot --nginx -d yourdomain.com
Monitoring and Maintenance
Health Check Endpoint
src/app/api/health/route.ts:
export async function GET() {
const checks = {
database: await checkDatabase(),
cache: await checkCache(),
ai: await checkAIService(),
}
const healthy = Object.values(checks).every(v => v)
return NextResponse.json(
{ status: healthy ? 'healthy' : 'unhealthy', checks },
{ status: healthy ? 200 : 503 }
)
}
Backup Script
#!/bin/bash
# backup.sh
DATE=$(date +%Y%m%d_%H%M%S)
docker-compose -f docker-compose.prod.yml exec -T postgres pg_dump -U bible_admin bible_chat > backup_$DATE.sql
# Upload to S3 or other storage
Performance Benchmarks
- Page Load: < 2 seconds
- API Response: < 500ms (cached), < 2s (database)
- AI Chat Response: < 5 seconds
- WebSocket Latency: < 100ms
- Database Queries: < 50ms (with indexes)
Scalability Considerations
- Horizontal Scaling: Use Docker Swarm or Kubernetes
- Database Replication: PostgreSQL streaming replication
- CDN: CloudFlare for static assets
- Load Balancing: HAProxy or Nginx upstream
- Caching: Redis cluster for session storage
- Message Queue: RabbitMQ for background jobs
Security Checklist
- Environment variables secured
- HTTPS enforced
- Rate limiting implemented
- Input validation on all endpoints
- SQL injection prevention (Prisma)
- XSS protection headers
- CSRF tokens
- Regular dependency updates
- Database backups configured
- Monitoring alerts setup
Final Notes
This implementation plan provides a production-ready, scalable Biblical Guide application using only open-source technologies. The architecture is designed to handle 10,000+ concurrent users with proper scaling. Regular maintenance includes:
- Weekly database backups
- Monthly security updates
- Performance monitoring
- User feedback implementation
- AI model fine-tuning based on usage patterns
The modular structure allows for easy feature additions and modifications as the application grows.