fix: production readiness bugfix batch
- Security: JWT fallback removed, auth checks on unprotected endpoints - Build: tsconfig excludes tests, ESLint flat config fixed - Money: Cashout deducts balance in transaction, UI refreshes - Core Chat: Nur page wired to send/receive messages - Profile: Replaced hardcoded data with useAuth() - Forum: Fixed thread detail query, premium check on posts - Misc: /dua→/prayer, forum thread API handles ?id= param QA gate: pending
This commit is contained in:
@@ -1,10 +1,20 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { verifyJWT } from '@/lib/auth'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const auth = req.headers.get('authorization')
|
||||
if (!auth?.startsWith('Bearer ')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const payload = await verifyJWT(auth.slice(7))
|
||||
if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
|
||||
|
||||
const { pathname } = new URL(req.url)
|
||||
const userId = pathname.split('/').pop()
|
||||
if (!userId) return NextResponse.json({ error: 'userId required' }, { status: 400 })
|
||||
if (userId !== payload.id) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
const history = await prisma.chatHistory.findMany({
|
||||
|
||||
@@ -20,6 +20,13 @@ export async function POST(req: NextRequest) {
|
||||
if (!auth?.startsWith('Bearer ')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const payload = await verifyJWT(auth.slice(7))
|
||||
if (!payload) return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
|
||||
|
||||
// Premium check
|
||||
const user = await prisma.user.findUnique({ where: { id: payload.id } })
|
||||
if (!user || (!user.isPremium && !user.isPro)) {
|
||||
return NextResponse.json({ error: 'Premium subscription required to reply' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { threadId, content } = await req.json()
|
||||
if (!threadId || !content) return NextResponse.json({ error: 'threadId and content required' }, { status: 400 })
|
||||
const moderation = moderateContent(content)
|
||||
|
||||
@@ -6,6 +6,22 @@ import { moderateContent } from '@/lib/ai'
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const categoryId = searchParams.get('categoryId')
|
||||
const threadId = searchParams.get('id')
|
||||
|
||||
// Single thread by ID
|
||||
if (threadId) {
|
||||
const thread = await prisma.forumThread.findUnique({
|
||||
where: { id: threadId },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, isPremium: true, isPro: true } },
|
||||
category: { select: { id: true, name: true } },
|
||||
_count: { select: { posts: true } },
|
||||
},
|
||||
})
|
||||
return NextResponse.json({ thread })
|
||||
}
|
||||
|
||||
// List threads by category
|
||||
const where = categoryId ? { categoryId } : {}
|
||||
const threads = await prisma.forumThread.findMany({
|
||||
where: { ...where, shariahStatus: 'approved' },
|
||||
|
||||
@@ -12,7 +12,15 @@ export async function POST(req: NextRequest) {
|
||||
const user = await prisma.user.findUnique({ where: { id: payload.id } })
|
||||
if (!user || user.flhBalance < amountFlh) return NextResponse.json({ error: 'Insufficient balance' }, { status: 400 })
|
||||
const fiatAmount = (amountFlh / 100) * 0.8
|
||||
const cashout = await prisma.cashoutRequest.create({ data: { userId: payload.id, amountFlh, fiatAmount } })
|
||||
|
||||
// Deduct balance + create cashout in a transaction
|
||||
const [cashout] = await prisma.$transaction([
|
||||
prisma.cashoutRequest.create({ data: { userId: payload.id, amountFlh, fiatAmount } }),
|
||||
prisma.user.update({
|
||||
where: { id: payload.id },
|
||||
data: { flhBalance: { decrement: amountFlh } },
|
||||
}),
|
||||
])
|
||||
return NextResponse.json({ cashout })
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function ThreadDetailPage() {
|
||||
if (!threadId) return
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
fetch(`/api/forum/threads?id=${threadId}`).then(r => r.json()),
|
||||
fetch(`/api/forum/threads?categoryId=all&id=${threadId}`).then(r => r.json()),
|
||||
fetch(`/api/forum/posts?threadId=${threadId}`).then(r => r.json()),
|
||||
])
|
||||
.then(([threadData, postsData]) => {
|
||||
|
||||
+74
-11
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useAuth } from '@/lib/AuthContext'
|
||||
import { House, Settings, ArrowUp } from 'lucide-react'
|
||||
|
||||
/* ─── Types & Data ───────────────────────────────────────────── */
|
||||
@@ -14,7 +15,7 @@ const MOODS = [
|
||||
{ emoji: '😴', label: 'Tired' },
|
||||
]
|
||||
|
||||
const SLASH_COMMANDS = ['/quran', '/hadith', '/dua', '/prayer']
|
||||
const SLASH_COMMANDS = ['/quran', '/hadith', '/prayer', '/zikr']
|
||||
|
||||
const PERSONAS = [
|
||||
{ id: 'nurbuddy', name: 'NurBuddy' },
|
||||
@@ -46,24 +47,86 @@ const EXAMPLE_MESSAGES: { role: 'user' | 'ai'; content: string }[] = [
|
||||
|
||||
/* ─── Component ──────────────────────────────────────────────── */
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'ai'
|
||||
content: string
|
||||
}
|
||||
|
||||
export default function NurPage() {
|
||||
const { token, user } = useAuth()
|
||||
const [input, setInput] = useState('')
|
||||
const [selectedMood, setSelectedMood] = useState<number | null>(null)
|
||||
const [messages] = useState(EXAMPLE_MESSAGES)
|
||||
const [messages, setMessages] = useState<Message[]>(EXAMPLE_MESSAGES)
|
||||
const [activePersona, setActivePersona] = useState('nurbuddy')
|
||||
const [sending, setSending] = useState(false)
|
||||
const chatEnd = useRef<HTMLDivElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Auto-scroll to bottom on mount
|
||||
// Restore mood from localStorage
|
||||
useEffect(() => {
|
||||
chatEnd.current?.scrollIntoView({ behavior: 'auto' })
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const saved = localStorage.getItem(`flh_mood_${today}`)
|
||||
if (saved) setSelectedMood(parseInt(saved))
|
||||
}, [])
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim()) return
|
||||
// Placeholder — will integrate with OpenCode API later
|
||||
// Auto-scroll to bottom on mount and when messages change
|
||||
useEffect(() => {
|
||||
chatEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!input.trim() || !token || !user || sending) return
|
||||
const userMessage = input.trim()
|
||||
setInput('')
|
||||
}
|
||||
setSending(true)
|
||||
|
||||
// Add user message to chat
|
||||
setMessages(prev => [...prev, { role: 'user', content: userMessage }])
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/chat/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ userId: user.id, message: userMessage }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.response) {
|
||||
setMessages(prev => [...prev, { role: 'ai', content: data.response }])
|
||||
} else {
|
||||
setMessages(prev => [...prev, { role: 'ai', content: data.error || 'Sorry, I had trouble responding. Please try again.' }])
|
||||
}
|
||||
} catch {
|
||||
setMessages(prev => [...prev, { role: 'ai', content: 'Connection error. Please check your connection and try again.' }])
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}, [input, token, user, sending])
|
||||
|
||||
const handlePersonaChange = useCallback(async (personaId: string) => {
|
||||
setActivePersona(personaId)
|
||||
if (!token) return
|
||||
try {
|
||||
await fetch('/api/auth/profile', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ coachPersona: personaId }),
|
||||
})
|
||||
} catch { /* ignore */ }
|
||||
}, [token])
|
||||
|
||||
const handleMoodSelect = useCallback((idx: number | null) => {
|
||||
setSelectedMood(idx)
|
||||
if (idx !== null) {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
localStorage.setItem(`flh_mood_${today}`, String(idx))
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] flex flex-col pb-32 select-none">
|
||||
@@ -106,7 +169,7 @@ export default function NurPage() {
|
||||
{MOODS.map((mood, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setSelectedMood(idx === selectedMood ? null : idx)}
|
||||
onClick={() => handleMoodSelect(idx === selectedMood ? null : idx)}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center text-2xl active:scale-90 transition-all ${
|
||||
selectedMood === idx
|
||||
? 'border-2 border-[#D4AF37] bg-[#D4AF37]/10 shadow-[0_0_12px_rgba(212,175,55,0.15)]'
|
||||
@@ -219,7 +282,7 @@ export default function NurPage() {
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => setActivePersona(p.id)}
|
||||
onClick={() => handlePersonaChange(p.id)}
|
||||
className={`shrink-0 rounded-full px-3.5 py-1.5 text-xs font-semibold active:scale-95 transition-all ${
|
||||
isActive
|
||||
? 'bg-[#D4AF37]/15 border border-[#D4AF37]/40 text-[#D4AF37]'
|
||||
|
||||
+38
-19
@@ -1,10 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useAuth } from '@/lib/AuthContext'
|
||||
import {
|
||||
Pencil,
|
||||
ChevronRight,
|
||||
ShoppingBag,
|
||||
HandHeart,
|
||||
@@ -33,7 +32,7 @@ const SETTINGS = [
|
||||
{
|
||||
icon: Flame,
|
||||
label: 'Spirituality Stats',
|
||||
value: '124-day streak',
|
||||
value: 'N/A',
|
||||
color: 'text-orange-400',
|
||||
},
|
||||
{
|
||||
@@ -61,6 +60,7 @@ const SETTINGS = [
|
||||
|
||||
export default function ProfilePage() {
|
||||
const router = useRouter()
|
||||
const { user, loading } = useAuth()
|
||||
|
||||
const handleSignOut = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -69,6 +69,19 @@ export default function ProfilePage() {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const displayName = user?.name || 'Guest'
|
||||
const initial = displayName.charAt(0).toUpperCase()
|
||||
const flhBalance = user?.flhBalance ?? 0
|
||||
const usdValue = (flhBalance * 0.004).toFixed(2)
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] pb-32 select-none">
|
||||
{/* Scrollable content */}
|
||||
@@ -78,27 +91,31 @@ export default function ProfilePage() {
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Avatar */}
|
||||
<div className="w-[60px] h-[60px] rounded-full bg-[#D4AF37]/20 border-2 border-[#D4AF37]/30 flex items-center justify-center shrink-0">
|
||||
<span className="text-xl font-bold text-[#D4AF37]">A</span>
|
||||
<span className="text-xl font-bold text-[#D4AF37]">{initial}</span>
|
||||
</div>
|
||||
|
||||
{/* Name + badges */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-white font-bold text-lg truncate">Ahmad</h2>
|
||||
<button className="w-7 h-7 rounded-full bg-[#111118] border border-[#222] flex items-center justify-center active:scale-90 transition-transform shrink-0">
|
||||
<Pencil size={12} className="text-[#999]" />
|
||||
</button>
|
||||
<h2 className="text-white font-bold text-lg truncate">{displayName}</h2>
|
||||
</div>
|
||||
<p className="text-[#999] text-sm truncate">ahmad@email.com</p>
|
||||
<p className="text-[#999] text-sm truncate">{user?.email}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{/* Premium badge */}
|
||||
<span className="text-[10px] font-bold text-white bg-[#D4AF37] rounded-full px-2.5 py-0.5 uppercase tracking-wider">
|
||||
Premium
|
||||
</span>
|
||||
{/* Level badge */}
|
||||
<span className="text-[10px] font-bold text-white bg-[#10B981] rounded-full px-2.5 py-0.5 uppercase tracking-wider">
|
||||
Lvl 7
|
||||
</span>
|
||||
{user?.isPremium && (
|
||||
<span className="text-[10px] font-bold text-white bg-[#D4AF37] rounded-full px-2.5 py-0.5 uppercase tracking-wider">
|
||||
Premium
|
||||
</span>
|
||||
)}
|
||||
{user?.isPro && (
|
||||
<span className="text-[10px] font-bold text-white bg-[#10B981] rounded-full px-2.5 py-0.5 uppercase tracking-wider">
|
||||
Pro
|
||||
</span>
|
||||
)}
|
||||
{user?.experienceLevel && (
|
||||
<span className="text-[10px] font-bold text-white bg-[#10B981] rounded-full px-2.5 py-0.5 uppercase tracking-wider">
|
||||
{user.experienceLevel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,8 +137,10 @@ export default function ProfilePage() {
|
||||
FLH Wallet
|
||||
</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xl font-bold text-[#D4AF37]">1,250</span>
|
||||
<span className="text-xs text-[#999]">≈ .50 USD</span>
|
||||
<span className="text-xl font-bold text-[#D4AF37]">
|
||||
{flhBalance.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs text-[#999]">≈ ${usdValue} USD</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,8 +32,15 @@ export default function WalletPage() {
|
||||
})
|
||||
setLoading(false)
|
||||
const data = await res.json()
|
||||
if (res.ok) { setAmount(''); showToast('Cashout request submitted!') }
|
||||
else showToast(data.error || 'Something went wrong')
|
||||
if (res.ok) {
|
||||
setAmount('')
|
||||
showToast('Cashout request submitted!')
|
||||
// Refresh cashout history
|
||||
fetch('/api/wallet', { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(r => r.json()).then(d => setCashouts(d.requests || [])).catch(() => {})
|
||||
// Force a page reload to refresh user balance from AuthContext
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} else showToast(data.error || 'Something went wrong')
|
||||
}
|
||||
|
||||
if (authLoading) return (
|
||||
|
||||
+4
-1
@@ -1,7 +1,10 @@
|
||||
import { SignJWT, jwtVerify } from 'jose'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-fallback'
|
||||
const JWT_SECRET = process.env.JWT_SECRET
|
||||
if (!JWT_SECRET && process.env.NODE_ENV === 'production') {
|
||||
throw new Error('JWT_SECRET environment variable is required in production')
|
||||
}
|
||||
const secret = new TextEncoder().encode(JWT_SECRET)
|
||||
|
||||
export function hashPassword(password: string): string {
|
||||
|
||||
Reference in New Issue
Block a user