diff --git a/docs/wiki/Production-Bugfix-Plan.md b/docs/wiki/Production-Bugfix-Plan.md new file mode 100644 index 0000000..48d34a6 --- /dev/null +++ b/docs/wiki/Production-Bugfix-Plan.md @@ -0,0 +1,177 @@ +# FalahMobile — Production Readiness Bugfix Plan + +> **Lead:** pi-agent · **Date:** 2026-07-06 · **Branch:** `staging` · **Target:** `stagingx.falahos.my` + +--- + +## Situation Report + +Complete codebase audit of FalahMobile identified **25 issues** across 6 independent domains. Zero file overlap between domains enables parallel execution. + +### Build Status (Before) + +| Check | Status | +|-------|--------| +| `next build` | ❌ Fails — TypeScript errors | +| `tsc --noEmit` | ❌ 10 errors (missing deps, conflicting types) | +| ESLint | ❌ Circular dependency crash | +| Vitest | ❌ Missing deps | +| Playwright | ❌ Missing deps | + +--- + +## Squad Execution Plan + +``` + ┌─────────────────────────────────────┐ + │ Lead: pi-agent │ + │ - Branch: staging │ + │ - Coordination & QA gate │ + │ - Gitea wiki │ + └─────────────────────────────────────┘ + │ + ┌───────────┬───────────────┼───────────────┬───────────┐ + │ │ │ │ │ + ┌────┴────┐ ┌────┴────┐ ┌──────┴──────┐ ┌──────┴──────┐ ┌──┴──────┐ + │Squad 1 │ │Squad 2 │ │ Squad 3 │ │ Squad 4 │ │Squad 5 │ + │Security │ │ Build │ │ Money │ │ Core Chat │ │ Profile │ + │OpenCode │ │OpenCode │ │ OpenCode │ │ OpenCode │ │Claude │ + └────┬────┘ └────┬────┘ └──────┬──────┘ └──────┬──────┘ └──┬──────┘ + │ │ │ │ │ + 3 files 3 files 2 files 1 file 1 file +``` + +| Squad | Commander | Files | Task | +|-------|-----------|-------|------| +| 🛡️ **Security** | OpenCode | `chat/daily/route.ts`, `chat/history/[userId]/route.ts`, `forum/posts/route.ts` | Add auth verification & premium checks | +| 🏗️ **Build** | OpenCode | `package.json`, `node_modules/` | Install missing devDeps, fix vitest setup | +| 💰 **Money** | OpenCode | `wallet/route.ts`, `wallet/page.tsx` | Fix cashout balance deduction, UI refresh | +| 🧠 **Core Chat** | OpenCode | `nur/page.tsx` | Wire send/receive, persona API, mood check-in | +| 👤 **Profile** | Claude | `profile/page.tsx` | Replace hardcoded data with `useAuth()` | + +### Quick Fixes (Applied Directly by Lead) + +| # | Fix | File | +|---|-----|------| +| 1 | Remove JWT fallback secret — fail loudly in prod | `src/lib/auth.ts` | +| 2 | Fix ESLint config — remove circular dependency | `eslint.config.mjs` | +| 3 | Fix tsconfig — exclude test files from build check | `tsconfig.json` | +| 4 | Fix `.gitignore` — wildcard `.env*` coverage | `.gitignore` | +| 5 | Fix `/dua` → `/prayer` slash command | `nur/page.tsx` | +| 6 | Fix forum thread detail query param | `forum/[threadId]/page.tsx` | + +--- + +## QA Gate Checklist + +> **Non-negotiable.** Every item must pass before staging deployment. + +### Build & Type Safety +``` +[ ] next build --webpack — Compiles without errors +[ ] tsc --noEmit — Zero type errors +[ ] eslint src/ — Zero lint errors +``` + +### Tests +``` +[ ] npx vitest run — All unit tests pass +[ ] npx playwright test — All e2e tests pass +``` + +### Functional Verification +``` +[ ] Auth flow: register → login → protected route +[ ] Cashout flow: create cashout → balance deducted +[ ] Nur chat: send message → AI responds → message appears +[ ] Persona switch: change scholar → API called → persisted +[ ] Mood check-in: select mood → stored in localStorage +[ ] Forum: create thread → post reply → view thread detail +[ ] Prayer times: loads with geolocation +[ ] Halal Monitor: search city → shows map with markers +[ ] Wallet: balance displayed correctly → cashout submitted +``` + +### Security +``` +[ ] All API endpoints auth-protected (except login/register) +[ ] No hardcoded secrets in source code +[ ] Premium-gated features return 403 for free users +``` + +### Docker & Deployment +``` +[ ] docker build -t falah-mobile:staging . — Builds successfully +[ ] Docker Swarm stack deploys cleanly +[ ] Health check passes after deployment +[ ] Traefik routes correctly +``` + +--- + +## Deployment Runbook — Staging + +### 1. Build Docker Image +```bash +docker build -t falahos/falah-mobile:staging . +``` + +### 2. Push to Registry +```bash +docker tag falahos/falah-mobile:staging git.falahos.my/falahos/falah-mobile:staging +docker push git.falahos.my/falahos/falah-mobile:staging +``` + +### 3. Deploy to Swarm +```bash +# Update the service with the new image +docker service update \ + --image git.falahos.my/falahos/falah-mobile:staging \ + --with-registry-auth \ + falah_falah-mobile +``` + +### 4. Verify +```bash +docker service ps falah_falah-mobile +curl -I https://stagingx.falahos.my/mobile +``` + +### 5. Rollback (if needed) +```bash +docker service rollback falah_falah-mobile +``` + +--- + +## Infrastructure + +### Docker Swarm Stack +- **Manager Node:** `vmi3361598` (Contabo VPS, 8GB RAM) +- **Stack Name:** `falah` +- **Service:** `falah_falah-mobile` +- **Image:** `falahos/falah-mobile:latest` → staging tag +- **Proxy:** Traefik with Let's Encrypt +- **Route:** `Host(falahos.my) && PathPrefix(/mobile)` +- **Data:** SQLite on Docker volume `falah-mobile-data` + +### Credentials (Bitwarden: `bitwarden.falahos.my`) +- **Gitea:** `wmj` / `Abedib@99` at `git.falahos.my` +- **Staging SSH:** `root@13.140.161.244` / `Abedib@99` +- **Gitea API Token:** `hermes-bot` token + +--- + +## Rollback Procedure + +```bash +# Rollback the service to previous image +ssh root@13.140.161.244 +docker service rollback falah_falah-mobile +docker service ps falah_falah-mobile + +# If rollback fails, redeploy from known-good backup +docker service update \ + --image falahos/falah-mobile:latest \ + falah_falah-mobile +``` diff --git a/eslint.config.mjs b/eslint.config.mjs index c3da9f3..98c0256 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,13 +1,20 @@ -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import { FlatCompat } from "@eslint/eslintrc"; +// ESLint flat config for FalahMobile +// Uses @eslint/js directly to avoid eslintrc circular dependency +import js from '@eslint/js' +import nextPlugin from '@next/eslint-plugin-next' -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const compat = new FlatCompat({ - baseDirectory: __dirname, -}); - -const eslintConfig = [...compat.extends("next/core-web-vitals")]; -export default eslintConfig; +export default [ + js.configs.recommended, + { + plugins: { + '@next/next': nextPlugin, + }, + rules: { + ...nextPlugin.configs.recommended.rules, + ...nextPlugin.configs['core-web-vitals'].rules, + }, + }, + { + ignores: ['**/node_modules/**', '.next/**', '**/*.js', '**/*.mjs'], + }, +] diff --git a/package.json b/package.json index a93821d..6e15051 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,15 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@playwright/test": "^1.52.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@vitejs/plugin-react": "^4.4.1", "eslint": "^9", "eslint-config-next": "16.2.7", + "jsdom": "^26.0.0", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^3.1.1" } } diff --git a/src/app/api/chat/history/[userId]/route.ts b/src/app/api/chat/history/[userId]/route.ts index 66299e7..dceeaea 100644 --- a/src/app/api/chat/history/[userId]/route.ts +++ b/src/app/api/chat/history/[userId]/route.ts @@ -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({ diff --git a/src/app/api/forum/posts/route.ts b/src/app/api/forum/posts/route.ts index d692179..89b0f5d 100644 --- a/src/app/api/forum/posts/route.ts +++ b/src/app/api/forum/posts/route.ts @@ -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) diff --git a/src/app/api/forum/threads/route.ts b/src/app/api/forum/threads/route.ts index 381a0d4..3f04715 100644 --- a/src/app/api/forum/threads/route.ts +++ b/src/app/api/forum/threads/route.ts @@ -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' }, diff --git a/src/app/api/wallet/route.ts b/src/app/api/wallet/route.ts index 971cd1c..d9907e2 100644 --- a/src/app/api/wallet/route.ts +++ b/src/app/api/wallet/route.ts @@ -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 }) } diff --git a/src/app/forum/[threadId]/page.tsx b/src/app/forum/[threadId]/page.tsx index 296ecc5..374b301 100644 --- a/src/app/forum/[threadId]/page.tsx +++ b/src/app/forum/[threadId]/page.tsx @@ -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]) => { diff --git a/src/app/nur/page.tsx b/src/app/nur/page.tsx index be960fd..bf28e9e 100644 --- a/src/app/nur/page.tsx +++ b/src/app/nur/page.tsx @@ -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(null) - const [messages] = useState(EXAMPLE_MESSAGES) + const [messages, setMessages] = useState(EXAMPLE_MESSAGES) const [activePersona, setActivePersona] = useState('nurbuddy') + const [sending, setSending] = useState(false) const chatEnd = useRef(null) const scrollRef = useRef(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 (
@@ -106,7 +169,7 @@ export default function NurPage() { {MOODS.map((mood, idx) => ( +

{displayName}

-

ahmad@email.com

+

{user?.email}

- {/* Premium badge */} - - Premium - - {/* Level badge */} - - Lvl 7 - + {user?.isPremium && ( + + Premium + + )} + {user?.isPro && ( + + Pro + + )} + {user?.experienceLevel && ( + + {user.experienceLevel} + + )}
@@ -120,8 +137,10 @@ export default function ProfilePage() { FLH Wallet

- 1,250 - ≈ .50 USD + + {flhBalance.toLocaleString()} + + ≈ ${usdValue} USD
diff --git a/src/app/wallet/page.tsx b/src/app/wallet/page.tsx index da03f02..f9bb76a 100644 --- a/src/app/wallet/page.tsx +++ b/src/app/wallet/page.tsx @@ -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 ( diff --git a/src/lib/auth.ts b/src/lib/auth.ts index f305e10..9a40588 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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 { diff --git a/tsconfig.json b/tsconfig.json index b575f7d..99cda35 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,12 +30,18 @@ }, "include": [ "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts" + "src/**/*.ts", + "src/**/*.tsx" ], "exclude": [ - "node_modules" + "node_modules", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.spec.ts", + "**/__tests__/**", + "e2e/**", + "playwright.config.ts", + "vitest.config.ts", + "vitest.setup.ts" ] }