feat: add user settings save and reading plans with progress tracking

User Settings:
- Add /api/user/settings endpoint for persisting theme and fontSize preferences
- Update settings page with working save functionality
- Add validation and localized error messages

Reading Plans:
- Add database schema with ReadingPlan, UserReadingPlan, and UserReadingProgress models
- Create CRUD API endpoints for reading plans and progress tracking
- Build UI for browsing available plans and managing user enrollments
- Implement progress tracking with daily reading schedule
- Add streak calculation and statistics display
- Create seed data with 5 predefined plans (Bible in 1 year, 90 days, NT 30 days, Psalms 30, Gospels 30)
- Add navigation link with internationalization support

Technical:
- Update to MUI v7 Grid API (using size prop instead of xs/sm/md and removing item prop)
- Fix Next.js 15 dynamic route params (await params pattern)
- Add translations for readingPlans in all languages (en, ro, es, it)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-12 23:07:47 +00:00
parent 9d82e719ed
commit 63082c825a
15 changed files with 2065 additions and 3 deletions

View File

@@ -0,0 +1,234 @@
import { NextResponse } from 'next/server'
import { getUserFromToken } from '@/lib/auth'
import { prisma } from '@/lib/db'
export const runtime = 'nodejs'
/**
* GET /api/user/reading-plans/[id]/progress
* Get progress for a specific reading plan
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
// Get token from authorization header
const authHeader = request.headers.get('authorization')
const token = authHeader?.replace('Bearer ', '')
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify token and get user
const user = await getUserFromToken(token)
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify plan belongs to user
const userPlan = await prisma.userReadingPlan.findUnique({
where: {
id,
userId: user.id
}
})
if (!userPlan) {
return NextResponse.json(
{ error: 'Reading plan not found' },
{ status: 404 }
)
}
const progress = await prisma.userReadingProgress.findMany({
where: {
userPlanId: id,
userId: user.id
},
orderBy: {
planDay: 'asc'
}
})
return NextResponse.json({
success: true,
progress
})
} catch (error) {
console.error('Reading progress fetch error:', error)
return NextResponse.json(
{ error: 'Failed to fetch reading progress' },
{ status: 500 }
)
}
}
/**
* POST /api/user/reading-plans/[id]/progress
* Mark a reading as complete
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
// Get token from authorization header
const authHeader = request.headers.get('authorization')
const token = authHeader?.replace('Bearer ', '')
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify token and get user
const user = await getUserFromToken(token)
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { planDay, bookId, chapterNum, versesRead, completed, notes } = body
// Validate required fields
if (!planDay || !bookId || !chapterNum) {
return NextResponse.json(
{ error: 'planDay, bookId, and chapterNum are required' },
{ status: 400 }
)
}
// Verify plan belongs to user
const userPlan = await prisma.userReadingPlan.findUnique({
where: {
id,
userId: user.id
}
})
if (!userPlan) {
return NextResponse.json(
{ error: 'Reading plan not found' },
{ status: 404 }
)
}
// Create or update progress entry
const progress = await prisma.userReadingProgress.upsert({
where: {
userPlanId_planDay_bookId_chapterNum: {
userPlanId: id,
planDay: parseInt(planDay),
bookId: bookId,
chapterNum: parseInt(chapterNum)
}
},
create: {
userId: user.id,
userPlanId: id,
planDay: parseInt(planDay),
bookId: bookId,
chapterNum: parseInt(chapterNum),
versesRead: versesRead || null,
completed: completed !== false,
notes: notes || null
},
update: {
completed: completed !== false,
versesRead: versesRead || null,
notes: notes || null,
updatedAt: new Date()
}
})
// Update user plan statistics
if (completed !== false) {
// Count total completed days
const completedDays = await prisma.userReadingProgress.count({
where: {
userPlanId: id,
userId: user.id,
completed: true
}
})
// Calculate streak
const allProgress = await prisma.userReadingProgress.findMany({
where: {
userPlanId: id,
userId: user.id,
completed: true
},
orderBy: {
date: 'desc'
},
select: {
date: true
}
})
let currentStreak = 0
let longestStreak = 0
let tempStreak = 0
let lastDate: Date | null = null
for (const entry of allProgress) {
if (!lastDate) {
tempStreak = 1
lastDate = new Date(entry.date)
} else {
const dayDiff = Math.floor((lastDate.getTime() - new Date(entry.date).getTime()) / (1000 * 60 * 60 * 24))
if (dayDiff === 1) {
tempStreak++
} else {
if (tempStreak > longestStreak) {
longestStreak = tempStreak
}
tempStreak = 1
}
lastDate = new Date(entry.date)
}
}
currentStreak = tempStreak
if (currentStreak > longestStreak) {
longestStreak = currentStreak
}
// Update current day if this is the latest completed day
const maxDay = parseInt(planDay)
const shouldUpdateCurrentDay = maxDay >= userPlan.currentDay
await prisma.userReadingPlan.update({
where: { id },
data: {
completedDays: completedDays,
streak: currentStreak,
longestStreak: Math.max(longestStreak, userPlan.longestStreak),
...(shouldUpdateCurrentDay && { currentDay: maxDay + 1 })
}
})
}
return NextResponse.json({
success: true,
message: 'Reading progress updated successfully',
progress
})
} catch (error) {
console.error('Reading progress update error:', error)
return NextResponse.json(
{ error: 'Failed to update reading progress' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,230 @@
import { NextResponse } from 'next/server'
import { getUserFromToken } from '@/lib/auth'
import { prisma } from '@/lib/db'
export const runtime = 'nodejs'
/**
* GET /api/user/reading-plans/[id]
* Get a specific reading plan with progress
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
// Get token from authorization header
const authHeader = request.headers.get('authorization')
const token = authHeader?.replace('Bearer ', '')
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify token and get user
const user = await getUserFromToken(token)
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userPlan = await prisma.userReadingPlan.findUnique({
where: {
id,
userId: user.id
},
include: {
plan: {
select: {
id: true,
name: true,
description: true,
duration: true,
difficulty: true,
schedule: true,
type: true
}
},
progress: {
orderBy: {
planDay: 'asc'
}
}
}
})
if (!userPlan) {
return NextResponse.json(
{ error: 'Reading plan not found' },
{ status: 404 }
)
}
return NextResponse.json({
success: true,
plan: userPlan
})
} catch (error) {
console.error('Reading plan fetch error:', error)
return NextResponse.json(
{ error: 'Failed to fetch reading plan' },
{ status: 500 }
)
}
}
/**
* PUT /api/user/reading-plans/[id]
* Update a reading plan (pause, resume, complete, cancel)
*/
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
// Get token from authorization header
const authHeader = request.headers.get('authorization')
const token = authHeader?.replace('Bearer ', '')
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify token and get user
const user = await getUserFromToken(token)
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { status, reminderEnabled, reminderTime } = body
// Verify plan belongs to user
const existingPlan = await prisma.userReadingPlan.findUnique({
where: {
id,
userId: user.id
}
})
if (!existingPlan) {
return NextResponse.json(
{ error: 'Reading plan not found' },
{ status: 404 }
)
}
const updateData: any = {}
if (status !== undefined) {
const validStatuses = ['ACTIVE', 'COMPLETED', 'PAUSED', 'CANCELLED']
if (!validStatuses.includes(status)) {
return NextResponse.json(
{ error: 'Invalid status' },
{ status: 400 }
)
}
updateData.status = status
// If completing, set actualEndDate
if (status === 'COMPLETED') {
updateData.actualEndDate = new Date()
}
}
if (reminderEnabled !== undefined) {
updateData.reminderEnabled = reminderEnabled
}
if (reminderTime !== undefined) {
updateData.reminderTime = reminderTime
}
const updatedPlan = await prisma.userReadingPlan.update({
where: { id },
data: updateData,
include: {
plan: true
}
})
return NextResponse.json({
success: true,
message: 'Reading plan updated successfully',
plan: updatedPlan
})
} catch (error) {
console.error('Reading plan update error:', error)
return NextResponse.json(
{ error: 'Failed to update reading plan' },
{ status: 500 }
)
}
}
/**
* DELETE /api/user/reading-plans/[id]
* Delete a reading plan
*/
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
// Get token from authorization header
const authHeader = request.headers.get('authorization')
const token = authHeader?.replace('Bearer ', '')
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify token and get user
const user = await getUserFromToken(token)
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify plan belongs to user
const existingPlan = await prisma.userReadingPlan.findUnique({
where: {
id,
userId: user.id
}
})
if (!existingPlan) {
return NextResponse.json(
{ error: 'Reading plan not found' },
{ status: 404 }
)
}
// Delete the plan (cascade will delete progress too)
await prisma.userReadingPlan.delete({
where: { id }
})
return NextResponse.json({
success: true,
message: 'Reading plan deleted successfully'
})
} catch (error) {
console.error('Reading plan delete error:', error)
return NextResponse.json(
{ error: 'Failed to delete reading plan' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,181 @@
import { NextResponse } from 'next/server'
import { getUserFromToken } from '@/lib/auth'
import { prisma } from '@/lib/db'
export const runtime = 'nodejs'
/**
* GET /api/user/reading-plans
* Get all reading plans for the authenticated user
*/
export async function GET(request: Request) {
try {
// Get token from authorization header
const authHeader = request.headers.get('authorization')
const token = authHeader?.replace('Bearer ', '')
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify token and get user
const user = await getUserFromToken(token)
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const url = new URL(request.url)
const status = url.searchParams.get('status') || 'ACTIVE'
const userPlans = await prisma.userReadingPlan.findMany({
where: {
userId: user.id,
...(status !== 'ALL' && { status: status as any })
},
include: {
plan: {
select: {
id: true,
name: true,
description: true,
duration: true,
difficulty: true,
type: true
}
}
},
orderBy: {
createdAt: 'desc'
}
})
return NextResponse.json({
success: true,
plans: userPlans
})
} catch (error) {
console.error('User reading plans fetch error:', error)
return NextResponse.json(
{ error: 'Failed to fetch reading plans' },
{ status: 500 }
)
}
}
/**
* POST /api/user/reading-plans
* Enroll user in a reading plan
*/
export async function POST(request: Request) {
try {
// Get token from authorization header
const authHeader = request.headers.get('authorization')
const token = authHeader?.replace('Bearer ', '')
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify token and get user
const user = await getUserFromToken(token)
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { planId, startDate, customSchedule, name } = body
// Validate input
if (!planId && !customSchedule) {
return NextResponse.json(
{ error: 'Either planId or customSchedule is required' },
{ status: 400 }
)
}
let planData: any = {}
let duration = 365 // Default duration
if (planId) {
// Enrolling in a predefined plan
const plan = await prisma.readingPlan.findUnique({
where: { id: planId }
})
if (!plan) {
return NextResponse.json(
{ error: 'Reading plan not found' },
{ status: 404 }
)
}
if (!plan.isActive) {
return NextResponse.json(
{ error: 'This reading plan is no longer available' },
{ status: 400 }
)
}
duration = plan.duration
planData = {
planId: plan.id,
name: plan.name
}
} else {
// Creating a custom plan
if (!name) {
return NextResponse.json(
{ error: 'Name is required for custom plans' },
{ status: 400 }
)
}
if (!customSchedule || !Array.isArray(customSchedule)) {
return NextResponse.json(
{ error: 'Valid customSchedule is required' },
{ status: 400 }
)
}
duration = customSchedule.length
planData = {
name,
customSchedule
}
}
// Calculate target end date
const start = startDate ? new Date(startDate) : new Date()
const targetEnd = new Date(start)
targetEnd.setDate(targetEnd.getDate() + duration)
// Create user reading plan
const userPlan = await prisma.userReadingPlan.create({
data: {
userId: user.id,
startDate: start,
targetEndDate: targetEnd,
...planData
},
include: {
plan: true
}
})
return NextResponse.json({
success: true,
message: 'Successfully enrolled in reading plan',
plan: userPlan
})
} catch (error) {
console.error('Reading plan enrollment error:', error)
return NextResponse.json(
{ error: 'Failed to enroll in reading plan' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,103 @@
import { NextResponse } from 'next/server'
import { getUserFromToken } from '@/lib/auth'
import { prisma } from '@/lib/db'
export const runtime = 'nodejs'
function getErrorMessages(locale: string = 'ro') {
const messages = {
ro: {
unauthorized: 'Nu esti autentificat',
updateFailed: 'Actualizarea setărilor a eșuat',
success: 'Setări actualizate cu succes',
invalidData: 'Date invalide'
},
en: {
unauthorized: 'Unauthorized',
updateFailed: 'Settings update failed',
success: 'Settings updated successfully',
invalidData: 'Invalid data'
}
}
return messages[locale as keyof typeof messages] || messages.ro
}
export async function PUT(request: Request) {
try {
const url = new URL(request.url)
const locale = url.searchParams.get('locale') || 'ro'
const messages = getErrorMessages(locale)
// Get token from authorization header
const authHeader = request.headers.get('authorization')
const token = authHeader?.replace('Bearer ', '')
if (!token) {
return NextResponse.json({ error: messages.unauthorized }, { status: 401 })
}
// Verify token and get user
const user = await getUserFromToken(token)
if (!user) {
return NextResponse.json({ error: messages.unauthorized }, { status: 401 })
}
// Parse request body
const body = await request.json()
const { theme, fontSize, notifications, emailUpdates, language } = body
// Validate input - allow partial updates
const updateData: any = {}
if (theme !== undefined) {
if (!['light', 'dark', 'auto'].includes(theme)) {
return NextResponse.json({ error: messages.invalidData }, { status: 400 })
}
updateData.theme = theme
}
if (fontSize !== undefined) {
if (!['small', 'medium', 'large'].includes(fontSize)) {
return NextResponse.json({ error: messages.invalidData }, { status: 400 })
}
updateData.fontSize = fontSize
}
// Note: notifications and emailUpdates would need additional columns in User model
// For now, we'll skip them or store in a JSON field if needed
// Update user settings
const updatedUser = await prisma.user.update({
where: { id: user.id },
data: updateData,
select: {
id: true,
email: true,
name: true,
role: true,
theme: true,
fontSize: true,
subscriptionTier: true,
subscriptionStatus: true,
conversationLimit: true,
conversationCount: true,
limitResetDate: true
}
})
return NextResponse.json({
success: true,
message: messages.success,
user: updatedUser
})
} catch (error) {
console.error('Settings update error:', error)
const url = new URL(request.url)
const locale = url.searchParams.get('locale') || 'ro'
const messages = getErrorMessages(locale)
return NextResponse.json({ error: messages.updateFailed }, { status: 500 })
}
}