# 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 ```bash # 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):** ```yaml 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:** ```bash npm install @prisma/client prisma @types/node npm install -D @types/bcryptjs bcryptjs ``` **Prisma Schema (prisma/schema.prisma):** ```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):** ```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:** ```env 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:** ```typescript 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:** ```typescript 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:** ```typescript 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:** ```typescript 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:** ```typescript 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:** ```typescript 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:** ```typescript export async function generateEmbedding(text: string): Promise { 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:** ```typescript 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:** ```bash 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:** ```typescript 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()( 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:** ```typescript import './globals.css' import { Providers } from '@/components/providers' import { Navigation } from '@/components/navigation' export default function RootLayout({ children, }: { children: React.ReactNode }) { return (
{children}
) } ``` ### Step 4.4: Bible Reader Component **src/components/bible/reader.tsx:** ```typescript '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
Loading...
return (
{verses.map((verse: any) => ( handleVerseClick(verse.id)} > {verse.verseNum} {verse.text} ))}
) } ``` ### Step 4.5: AI Chat Interface **src/components/chat/chat-interface.tsx:** ```typescript '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 (
{messages.map((msg, idx) => (
{msg.content}
))} {loading && (
)}
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} />
) } ``` ### Step 4.6: Real-time Prayer Wall **src/components/prayer/prayer-wall.tsx:** ```typescript 'use client' import { useEffect, useState } from 'react' import { io, Socket } from 'socket.io-client' export function PrayerWall() { const [socket, setSocket] = useState(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 (