From 299785ee682c6230914c212ac339548829a8e322 Mon Sep 17 00:00:00 2001 From: Fahd Sultan Date: Wed, 10 Sep 2025 13:26:02 +0500 Subject: [PATCH 1/2] Ported to PG --- .gitignore | 4 +- README.md | 54 +- app/api/auth/[...nextauth]/route.ts | 6 + app/api/auth/refresh/route.ts | 40 + app/api/auth/register/route.ts | 60 + app/api/comments/route.ts | 138 ++ app/api/dashboard/route.ts | 42 + app/api/discussions/route.ts | 108 ++ app/api/processes/[id]/route.ts | 157 +++ app/api/processes/route.ts | 112 ++ app/api/proposals/[id]/route.ts | 120 ++ app/api/proposals/route.ts | 120 ++ app/api/vote/route.ts | 130 ++ app/dashboard/page.tsx | 130 +- app/layout.tsx | 55 +- app/login/page.tsx | 221 ++- app/page.tsx | 36 +- app/processes/[id]/page.tsx | 526 +------- app/processes/components/comment-form.tsx | 74 + app/processes/components/discussion-form.tsx | 81 ++ .../components/new-proposal-form.tsx | 137 ++ app/processes/new/page.tsx | 377 ++++-- app/processes/page.tsx | 59 +- app/register/page.tsx | 258 ++-- components/main-nav.tsx | 16 +- components/react-query-provider.tsx | 22 + components/session-provider.tsx | 11 + hooks/useDebounce.ts | 18 + lib/auth-context.tsx | 82 +- lib/auth.ts | 113 ++ lib/axios.ts | 127 ++ lib/forms.ts | 54 + lib/jwt.ts | 105 ++ lib/mutations.ts | 214 +++ lib/prisma.ts | 12 + lib/queries.ts | 65 + lib/refresh-auth.ts | 36 + middleware.ts | 16 + package.json | 28 +- pnpm-lock.yaml | 1196 ++++++++++++++++- pnpx | 0 .../20250901200248_init/migration.sql | 331 +++++ prisma/migrations/migration_lock.toml | 3 + prisma/prisma.config.ts | 0 prisma/schema.prisma | 311 +++++ prisma/seed-prod.ts | 18 + prisma/seed.ts | 19 + prisma/seeds/development.ts | 128 ++ prisma/seeds/production.ts | 10 + 49 files changed, 4941 insertions(+), 1039 deletions(-) create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 app/api/auth/refresh/route.ts create mode 100644 app/api/auth/register/route.ts create mode 100644 app/api/comments/route.ts create mode 100644 app/api/dashboard/route.ts create mode 100644 app/api/discussions/route.ts create mode 100644 app/api/processes/[id]/route.ts create mode 100644 app/api/processes/route.ts create mode 100644 app/api/proposals/[id]/route.ts create mode 100644 app/api/proposals/route.ts create mode 100644 app/api/vote/route.ts create mode 100644 app/processes/components/comment-form.tsx create mode 100644 app/processes/components/discussion-form.tsx create mode 100644 app/processes/components/new-proposal-form.tsx create mode 100644 components/react-query-provider.tsx create mode 100644 components/session-provider.tsx create mode 100644 hooks/useDebounce.ts create mode 100644 lib/auth.ts create mode 100644 lib/axios.ts create mode 100644 lib/forms.ts create mode 100644 lib/jwt.ts create mode 100644 lib/mutations.ts create mode 100644 lib/prisma.ts create mode 100644 lib/queries.ts create mode 100644 lib/refresh-auth.ts create mode 100644 middleware.ts create mode 100644 pnpx create mode 100644 prisma/migrations/20250901200248_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/prisma.config.ts create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed-prod.ts create mode 100644 prisma/seed.ts create mode 100644 prisma/seeds/development.ts create mode 100644 prisma/seeds/production.ts diff --git a/.gitignore b/.gitignore index 5f9a7a8..eca0fde 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,6 @@ next-env.d.ts supabase/ CLAUDE.md -.mcp.json \ No newline at end of file +.mcp.json +/lib/generated/prisma +/.vscode/ diff --git a/README.md b/README.md index 3a594cf..12e9414 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Pakistan's Premier Participatory Democracy Platform** -Hum Awaz (ہم آواز) empowers Pakistani citizens to actively participate in democratic processes through digital consultations, proposal submissions, and collaborative decision-making. Built for transparency, accessibility, and meaningful civic engagement. +Hum Awaz (حم آواز) empowers Pakistani citizens to actively participate in democratic processes through digital consultations, proposal submissions, and collaborative decision-making. Built for transparency, accessibility, and meaningful civic engagement. --- @@ -73,7 +73,7 @@ Hum Awaz (ہم آواز) empowers Pakistani citizens to actively participate in ### **Database Schema** ``` profiles → User profiles and preferences -processes → Democratic consultation processes +processes → Democratic consultation processes proposals → Citizen-submitted proposals discussions → Comments and conversations votes → User votes on proposals @@ -93,7 +93,6 @@ notifications → User alerts and updates ### **Prerequisites** - Node.js 18+ and pnpm -- Supabase account and project - Git for version control ### **Installation** @@ -112,21 +111,41 @@ notifications → User alerts and updates 3. **Environment Setup** Create `.env.local` file: ```env - NEXT_PUBLIC_SUPABASE_URL=your_supabase_url - NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key + DATABASE_URL="postgresql://postgres:123@localhost:5432/hamnawa?schema=public" + + NEXT_PUBLIC_APP_URL="http://localhost:3000" + NEXT_PUBLIC_APP_API_URL="/api" + + # SECRET STRINGS + NEXT_JWT_ACCESS_SECRET="" + NEXT_JWT_REFRESH_SECRET="" + + NEXT_PUBLIC_APIS="/api/auth/login,/api/auth/register,/api/auth/refresh,/api/public/" + NEXT_PUBLIC_ENDPOINTS="/auth/login,/auth/register,/auth/refresh,/public/" + + # FOR GOOGLE OAUTH IF APPLICABLE + GOOGLE_CLIENT_ID="" + GOOGLE_CLIENT_SECRET="" + NODE_ENV='development' + ``` 4. **Database Setup** ```bash - # Run database migrations in order: - # 1. Drop existing tables (if any) - psql -h your-db-host -d your-db -f supabase/step1-drop-tables.sql - - # 2. Create schema and tables - psql -h your-db-host -d your-db -f supabase/step2-create-schema.sql - - # 3. Insert sample data - psql -h your-db-host -d your-db -f supabase/step3-sample-data.sql + # Reset the database + pnpx prisma db push --force-reset + + # Generate the migrations + pnpx prisma generate + + # Push the migrations to the DB + pnpx prisma db push + + # For seeding the development environment + pnpx tsx prisma/seed.ts + + # For seeding the production environment if applicable + pnpm run db:seed:prod ``` 5. **Run Development Server** @@ -137,6 +156,7 @@ notifications → User alerts and updates 6. **Open Application** Navigate to [http://localhost:3000](http://localhost:3000) + --- ## 📁 Project Structure @@ -275,7 +295,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file Hum Awaz is designed for collaboration with: - **Federal Government**: National policy consultations -- **Provincial Governments**: Regional governance initiatives +- **Provincial Governments**: Regional governance initiatives - **Local Governments**: Community-level decision making - **Civil Society**: NGO and advocacy group engagement - **Academic Institutions**: Research and analysis partnerships @@ -307,7 +327,7 @@ Hum Awaz envisions a Pakistan where every citizen has a meaningful voice in gove Our platform bridges the gap between citizens and government, fostering: - **Active Civic Engagement**: Beyond voting to continuous participation -- **Transparent Governance**: Open processes and accountable outcomes +- **Transparent Governance**: Open processes and accountable outcomes - **Inclusive Decision-Making**: Voices from all communities and backgrounds - **Evidence-Based Policy**: Data-driven insights for better governance @@ -342,7 +362,7 @@ Special thanks to: *Built with ❤️ for the people of Pakistan* -**Hum Awaz - ہم آواز - Voice of the People** +**Hum Awaz - حم آواز - Voice of the People** --- diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..b6149fb --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import NextAuth from "next-auth" +import { authOptions } from "@/lib/auth" + +const handler = NextAuth(authOptions) + +export { handler as GET, handler as POST } diff --git a/app/api/auth/refresh/route.ts b/app/api/auth/refresh/route.ts new file mode 100644 index 0000000..b0a77ac --- /dev/null +++ b/app/api/auth/refresh/route.ts @@ -0,0 +1,40 @@ +// app/api/auth/refresh/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { verifyRefreshToken, generateAccessToken } from '@/lib/jwt'; + +export async function POST(request: NextRequest) { + try { + const { refreshToken } = await request.json(); + + if (!refreshToken) { + return NextResponse.json( + { error: 'Refresh token is required' }, + { status: 400 } + ); + } + + // Verify the refresh token + const payload = verifyRefreshToken(refreshToken); + + // Generate new access token + const newAccessToken = generateAccessToken({ + userId: payload.userId, + email: payload.email, + isSuperuser: payload.isSuperuser, + isStaff: payload.isStaff, + permissions: payload.permissions, + }); + + return NextResponse.json({ + accessToken: newAccessToken, + message: 'Token refreshed successfully', + }); + + } catch (error) { + console.error('Token refresh error:', error); + return NextResponse.json( + { error: 'Invalid or expired refresh token' }, + { status: 401 } + ); + } +} diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..d72b57c --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,60 @@ +import prisma from '@/lib/prisma'; +import { NextRequest, NextResponse } from 'next/server'; +import type { NextApiRequest, NextApiResponse } from 'next'; +import { registerFormSchema } from '@/lib/forms'; +import bcrypt from 'bcryptjs'; + +export async function POST(request: NextRequest) { + console.log('[INFO] Inside register route'); + try { + const rawData = await request.json(); + const result = registerFormSchema.safeParse(rawData); + + if (!result.success) { + const fieldErrors = result.error.flatten().fieldErrors; + return NextResponse.json( + { + error: 'Validation failed', + fieldErrors: fieldErrors, + }, + { status: 400 } + ); + } + + const { full_name, email, password } = result.data; + + const existingUser = await prisma.user.findUnique({ + where: { email }, + }); + + if (existingUser) { + return NextResponse.json( + { + "error": "User with this email already exists", + }, + { status: 409 } + ); + } + + const hashedPassword = await bcrypt.hash(password, 10); + + const newUser = await prisma.user.create({ + data: { + name: full_name, + email, + password: hashedPassword, + }, + }); + + return NextResponse.json( + { message: 'User registered successfully', userId: newUser.id }, + { status: 201 } + ); + } catch (error) { + console.error('Login error:', error); + return NextResponse.json( + { error: 'Internal server error', fieldErrors: {} }, + { status: 500 } + ); + } +} diff --git a/app/api/comments/route.ts b/app/api/comments/route.ts new file mode 100644 index 0000000..eca5043 --- /dev/null +++ b/app/api/comments/route.ts @@ -0,0 +1,138 @@ +// app/api/processes/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/lib/auth'; +import prisma from '@/lib/prisma'; +import { discussionFormSchema, processFormSchema } from '@/lib/forms'; + +export async function GET(request: NextRequest) { + const session = await getServerSession(authOptions); + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + + // Extract query parameters + const search = searchParams.get('search') || ''; + const category = searchParams.get('category') || ''; + + // Build where conditions + const whereConditions: any = { + status: { in: ['active', 'closed'] }, + }; + + // Add search filter + if (search.trim()) { + whereConditions.OR = [ + { title: { contains: search, mode: 'insensitive' } }, + { description: { contains: search, mode: 'insensitive' } }, + ]; + } + + // Add category filter - only if category is specified and not 'all' + if (category.trim() && category !== 'all') { + whereConditions.category = { equals: category, mode: 'insensitive' }; + } + + const processes = await prisma.process.findMany({ + where: whereConditions, + orderBy: { created_at: 'desc' }, + include: { + proposals: { + include: { + discussions: true, + }, + }, + }, + }); + + return NextResponse.json(processes); +} + +export async function POST(request: NextRequest) { + console.log('[INFO] Inside POST comment route'); + try { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const rawData = await request.json(); + const result = discussionFormSchema.safeParse(rawData); + + if (!result.success) { + const fieldErrors = result.error.flatten().fieldErrors; + return NextResponse.json( + { + error: 'Validation failed', + fieldErrors: fieldErrors, + }, + { status: 400 } + ); + } + + const { comment, proposal_id, process_id } = result.data; + + const new_comment = await prisma.discussion.create({ + data: { + content: comment, + proposal: proposal_id + ? { + connect: { id: proposal_id }, + } + : undefined, + process: process_id + ? { + connect: { id: process_id }, + } + : undefined, + author: { + connect: { id: Number(session.user.id) }, + }, + }, + }); + + const proposal = await prisma.proposal.findUnique({ + where: { id: proposal_id }, + select: { process_id: true } + }); + + if (!proposal) { + throw new Error('Proposal not found'); + } + + await prisma.participation.upsert({ + where: { + user_id_process_id_participation_type: { + user_id: Number(session.user.id), + process_id: proposal.process_id, + participation_type: 'comment', + }, + }, + update: {}, + create: { + user: { + connect: { id: Number(session.user.id) } + }, + process: { + connect: { id: proposal.process_id } + }, + participation_type: 'comment', + }, + }); + + + return NextResponse.json( + { message: 'Comment submitted successfully', comment: new_comment.id, process_id: proposal.process_id}, + { status: 201 } + ); + } catch (error) { + console.error('Comment error:', error); + return NextResponse.json( + { error: 'Internal server error', fieldErrors: {} }, + { status: 500 } + ); + } +} diff --git a/app/api/dashboard/route.ts b/app/api/dashboard/route.ts new file mode 100644 index 0000000..33d01f6 --- /dev/null +++ b/app/api/dashboard/route.ts @@ -0,0 +1,42 @@ +// app/api/protected/route.ts +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth/next' +import { authOptions } from '@/lib/auth' +import prisma from '@/lib/prisma'; + +export async function GET(request: NextRequest) { + const session = await getServerSession(authOptions) + + if (!session) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ) + } + + + const processes = prisma.process.findMany({ + where: { status: "active" }, + orderBy: { created_at: 'desc' }, + take: 5, + }) + + const proposals = prisma.proposal.findMany({ + where: { author_id: Number(session.user.id) }, + orderBy: { created_at: 'desc' }, + }) + + const totalProcesses = await prisma.process.count() + const totalActiveProcesses = await prisma.process.count({ where: { status: "active" } }) + const totalProposals = await prisma.proposal.count() + const totalVotes = await prisma.vote.count() + + return NextResponse.json({ + processes: await processes, + proposals: await proposals, + totalProcesses: totalProcesses || 0, + totalActiveProcesses: totalActiveProcesses || 0, + totalProposals: totalProposals || 0, + totalVotes: totalVotes || 0, + }) +} diff --git a/app/api/discussions/route.ts b/app/api/discussions/route.ts new file mode 100644 index 0000000..214471c --- /dev/null +++ b/app/api/discussions/route.ts @@ -0,0 +1,108 @@ +// app/api/processes/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/lib/auth'; +import prisma from '@/lib/prisma'; +import { discussionFormSchema, processFormSchema } from '@/lib/forms'; + +export async function GET(request: NextRequest) { + const session = await getServerSession(authOptions); + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + + // Extract query parameters + const search = searchParams.get('search') || ''; + const category = searchParams.get('category') || ''; + + // Build where conditions + const whereConditions: any = { + status: { in: ['active', 'closed'] }, + }; + + // Add search filter + if (search.trim()) { + whereConditions.OR = [ + { title: { contains: search, mode: 'insensitive' } }, + { description: { contains: search, mode: 'insensitive' } }, + ]; + } + + // Add category filter - only if category is specified and not 'all' + if (category.trim() && category !== 'all') { + whereConditions.category = { equals: category, mode: 'insensitive' }; + } + + const processes = await prisma.process.findMany({ + where: whereConditions, + orderBy: { created_at: 'desc' }, + include: { + proposals: { + include: { + discussions: true, + }, + }, + }, + }); + + return NextResponse.json(processes); +} + +export async function POST(request: NextRequest) { + console.log('[INFO] Inside POST discussion route'); + try { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const rawData = await request.json(); + const result = discussionFormSchema.safeParse(rawData); + + if (!result.success) { + const fieldErrors = result.error.flatten().fieldErrors; + return NextResponse.json( + { + error: 'Validation failed', + fieldErrors: fieldErrors, + }, + { status: 400 } + ); + } + + const { comment, proposal_id, process_id } = result.data; + + const discussion = await prisma.discussion.create({ + data: { + content: comment, + proposal: proposal_id + ? { + connect: { id: proposal_id }, + } + : undefined, + process: process_id + ? { + connect: { id: process_id }, + } + : undefined, + author: { + connect: { id: Number(session.user.id) }, + }, + }, + }); + + return NextResponse.json( + { message: 'Discussion submitted successfully', discussion: discussion.id }, + { status: 201 } + ); + } catch (error) { + console.error('Discussion submitted error:', error); + return NextResponse.json( + { error: 'Internal server error', fieldErrors: {} }, + { status: 500 } + ); + } +} diff --git a/app/api/processes/[id]/route.ts b/app/api/processes/[id]/route.ts new file mode 100644 index 0000000..03ef9e9 --- /dev/null +++ b/app/api/processes/[id]/route.ts @@ -0,0 +1,157 @@ +// app/api/processes/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/lib/auth'; +import prisma from '@/lib/prisma'; +import { processFormSchema } from '@/lib/forms'; + +export async function GET( + request: NextRequest, + context: { params: Promise<{ id: string }> } // params is now a Promise +) { + const session = await getServerSession(authOptions); + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Await the params first + const params = await context.params; + const id = params.id; + + console.log('[INFO] Fetching process with ID:', id); + + // Convert id to number since your schema uses Int for user IDs + const processId = Number(id); + + if (isNaN(processId)) { + return NextResponse.json({ error: 'Invalid process ID' }, { status: 400 }); + } + + const process = await prisma.process.findUnique({ + where: { id: processId }, + include: { + creator: { + select: { + id: true, + name: true, + email: true, + }, + }, + discussions: true, + proposals: { + include: { + author: { + select: { + id: true, + name: true, + email: true, + }, + }, + discussions: true, + votes: { + where: { + user_id: Number(session.user.id) // This filters the votes relation for each proposal + }, + select: { + vote_type: true, // This is the crucial part: 'SUPPORT', 'OPPOSE', 'NEUTRAL' + } + }, + }, + }, + }, + }); + + if (!process) { + return NextResponse.json({ error: 'Process not found' }, { status: 404 }); + } + + const proposalsWithVoteCounts = await Promise.all( + process.proposals.map(async (proposal) => { + const supportCount = await prisma.vote.count({ + where: { proposal_id: proposal.id, vote_type: 'support' } + }); + const opposeCount = await prisma.vote.count({ + where: { proposal_id: proposal.id, vote_type: 'oppose' } + }); + const neutralCount = await prisma.vote.count({ + where: { proposal_id: proposal.id, vote_type: 'neutral' } + }); + return { + ...proposal, + voteCounts: { + support: supportCount, + oppose: opposeCount, + neutral: neutralCount + } + }; + }) + ); + + const result = { + ...process, + proposals: proposalsWithVoteCounts + }; + + return NextResponse.json(result); +} + +export async function POST(request: NextRequest) { + console.log('[INFO] Inside POST process route'); + try { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const rawData = await request.json(); + const result = processFormSchema.safeParse(rawData); + + if (!result.success) { + const fieldErrors = result.error.flatten().fieldErrors; + return NextResponse.json( + { + error: 'Validation failed', + fieldErrors: fieldErrors, + }, + { status: 400 } + ); + } + + const { + title, + title_ur, + description, + description_ur, + category, + organization, + end_date, + } = result.data; + + console.log("result.data", result.data) + + const process = await prisma.process.create({ + data: { + title, + title_ur, + description, + description_ur, + category, + organization, + end_date: new Date(end_date), + created_by: Number(session.user.id), + }, + }); + + return NextResponse.json( + { message: 'Process created successfully', process: process.id }, + { status: 201 } + ); + } catch (error) { + console.error('Process creation error:', error); + return NextResponse.json( + { error: 'Internal server error', fieldErrors: {} }, + { status: 500 } + ); + } +} diff --git a/app/api/processes/route.ts b/app/api/processes/route.ts new file mode 100644 index 0000000..f335bdb --- /dev/null +++ b/app/api/processes/route.ts @@ -0,0 +1,112 @@ +// app/api/processes/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/lib/auth'; +import prisma from '@/lib/prisma'; +import { processFormSchema } from '@/lib/forms'; + +export async function GET(request: NextRequest) { + const session = await getServerSession(authOptions); + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + + // Extract query parameters + const search = searchParams.get('search') || ''; + const category = searchParams.get('category') || ''; + + // Build where conditions + const whereConditions: any = { + status: { in: ['active', 'closed'] } + }; + + // Add search filter + if (search.trim()) { + whereConditions.OR = [ + { title: { contains: search, mode: 'insensitive' } }, + { description: { contains: search, mode: 'insensitive' } }, + ]; + } + + // Add category filter - only if category is specified and not 'all' + if (category.trim() && category !== 'all') { + whereConditions.category = { equals: category, mode: 'insensitive' }; + } + + const processes = await prisma.process.findMany({ + where: whereConditions, + orderBy: { created_at: 'desc' }, + include: { + proposals: { + include: { + discussions: true, + } + } + } + }); + + return NextResponse.json(processes); +} + +export async function POST(request: NextRequest) { + console.log('[INFO] Inside POST process route'); + try { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const rawData = await request.json(); + const result = processFormSchema.safeParse(rawData); + + if (!result.success) { + const fieldErrors = result.error.flatten().fieldErrors; + return NextResponse.json( + { + error: 'Validation failed', + fieldErrors: fieldErrors, + }, + { status: 400 } + ); + } + + const { + title, + title_ur, + description, + description_ur, + category, + organization, + end_date, + } = result.data; + + console.log("result.data", result.data) + + const process = await prisma.process.create({ + data: { + title, + title_ur, + description, + description_ur, + category, + organization, + end_date: new Date(end_date), + created_by: Number(session.user.id), + }, + }); + + return NextResponse.json( + { message: 'Process created successfully', process: process.id }, + { status: 201 } + ); + } catch (error) { + console.error('Process creation error:', error); + return NextResponse.json( + { error: 'Internal server error', fieldErrors: {} }, + { status: 500 } + ); + } +} diff --git a/app/api/proposals/[id]/route.ts b/app/api/proposals/[id]/route.ts new file mode 100644 index 0000000..af0c768 --- /dev/null +++ b/app/api/proposals/[id]/route.ts @@ -0,0 +1,120 @@ +// app/api/processes/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/lib/auth'; +import prisma from '@/lib/prisma'; +import { processFormSchema } from '@/lib/forms'; + +export async function GET( + request: NextRequest, + context: { params: Promise<{ id: string }> } // params is now a Promise +) { + const session = await getServerSession(authOptions); + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Await the params first + const params = await context.params; + const id = params.id; + + console.log('[INFO] Fetching process with ID:', id); + + // Convert id to number since your schema uses Int for user IDs + const processId = Number(id); + + if (isNaN(processId)) { + return NextResponse.json({ error: 'Invalid process ID' }, { status: 400 }); + } + + const process = await prisma.process.findUnique({ + where: { id: processId }, + include: { + creator: { + select: { + id: true, + name: true, + email: true, + }, + }, + proposals: { + include: { + author: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }, + }, + }); + + if (!process) { + return NextResponse.json({ error: 'Process not found' }, { status: 404 }); + } + + return NextResponse.json(process); +} + +export async function POST(request: NextRequest) { + console.log('[INFO] Inside POST process route'); + try { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const rawData = await request.json(); + const result = processFormSchema.safeParse(rawData); + + if (!result.success) { + const fieldErrors = result.error.flatten().fieldErrors; + return NextResponse.json( + { + error: 'Validation failed', + fieldErrors: fieldErrors, + }, + { status: 400 } + ); + } + + const { + title, + title_ur, + description, + description_ur, + category, + organization, + end_date, + } = result.data; + + console.log("result.data", result.data) + + const process = await prisma.process.create({ + data: { + title, + title_ur, + description, + description_ur, + category, + organization, + end_date: new Date(end_date), + created_by: Number(session.user.id), + }, + }); + + return NextResponse.json( + { message: 'Process created successfully', process: process.id }, + { status: 201 } + ); + } catch (error) { + console.error('Process creation error:', error); + return NextResponse.json( + { error: 'Internal server error', fieldErrors: {} }, + { status: 500 } + ); + } +} diff --git a/app/api/proposals/route.ts b/app/api/proposals/route.ts new file mode 100644 index 0000000..05b2da2 --- /dev/null +++ b/app/api/proposals/route.ts @@ -0,0 +1,120 @@ +// app/api/processes/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/lib/auth'; +import prisma from '@/lib/prisma'; +import { processFormSchema, proposalFormSchema } from '@/lib/forms'; + +export async function GET(request: NextRequest) { + const session = await getServerSession(authOptions); + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + + // Extract query parameters + const search = searchParams.get('search') || ''; + const category = searchParams.get('category') || ''; + + // Build where conditions + const whereConditions: any = { + status: { in: ['active', 'closed'] } + }; + + // Add search filter + if (search.trim()) { + whereConditions.OR = [ + { title: { contains: search, mode: 'insensitive' } }, + { description: { contains: search, mode: 'insensitive' } }, + ]; + } + + // Add category filter - only if category is specified and not 'all' + if (category.trim() && category !== 'all') { + whereConditions.category = { equals: category, mode: 'insensitive' }; + } + + const processes = await prisma.process.findMany({ + where: whereConditions, + orderBy: { created_at: 'desc' }, + }); + + return NextResponse.json(processes); +} + +export async function POST(request: NextRequest) { + console.log('[INFO] Inside POST proposals route'); + try { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const rawData = await request.json(); + const result = proposalFormSchema.safeParse(rawData); + + if (!result.success) { + const fieldErrors = result.error.flatten().fieldErrors; + return NextResponse.json( + { + error: 'Validation failed', + fieldErrors: fieldErrors, + }, + { status: 400 } + ); + } + + const { + title, + title_ur, + description, + description_ur, + process_id + } = result.data; + + console.log("result.data", result.data) + + if (typeof process_id !== 'number') { + return NextResponse.json( + { + error: 'Validation failed', + fieldErrors: { process_id: ['Process ID is required and must be a number'] }, + }, + { status: 400 } + ); + } + + const proposal = await prisma.proposal.create({ + data: { + title, + title_ur, + description, + description_ur, + process_id, + author_id: Number(session.user.id), + status: 'under_review', + }, + }); + + await prisma.participation.create({ + data: { + user_id: Number(session.user.id), + process_id: process_id, + participation_type: 'proposal' + }, + }) + + return NextResponse.json( + { message: 'Proposal created successfully', proposal: proposal.id }, + { status: 201 } + ); + } catch (error) { + console.error('Proposal creation error:', error); + return NextResponse.json( + { error: 'Internal server error', fieldErrors: {} }, + { status: 500 } + ); + } +} diff --git a/app/api/vote/route.ts b/app/api/vote/route.ts new file mode 100644 index 0000000..f9eced1 --- /dev/null +++ b/app/api/vote/route.ts @@ -0,0 +1,130 @@ +// app/api/processes/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/lib/auth'; +import prisma from '@/lib/prisma'; +import { processFormSchema } from '@/lib/forms'; + +export async function GET(request: NextRequest) { + const session = await getServerSession(authOptions); + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + + // Extract query parameters + const search = searchParams.get('search') || ''; + const category = searchParams.get('category') || ''; + + // Build where conditions + const whereConditions: any = { + status: { in: ['active', 'closed'] } + }; + + // Add search filter + if (search.trim()) { + whereConditions.OR = [ + { title: { contains: search, mode: 'insensitive' } }, + { description: { contains: search, mode: 'insensitive' } }, + ]; + } + + // Add category filter - only if category is specified and not 'all' + if (category.trim() && category !== 'all') { + whereConditions.category = { equals: category, mode: 'insensitive' }; + } + + const processes = await prisma.process.findMany({ + where: whereConditions, + orderBy: { created_at: 'desc' }, + include: { + proposals: { + include: { + discussions: true, + } + } + } + }); + + return NextResponse.json(processes); +} + +export async function POST(request: NextRequest) { + console.log('[INFO] Inside POST process route'); + try { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const rawData = await request.json(); + + const { + proposalId, + voteType, + } = rawData; + + const existingVote = await prisma.vote.findFirst({ + where: { + user_id: Number(session.user.id), + proposal_id: proposalId, + }, + }); + + if (existingVote) { + return NextResponse.json({ error: 'You have already voted on this proposal.' }, { status: 400 }); + } + + const vote = await prisma.vote.create({ + data: { + user: { + connect: { id: Number(session.user.id) } + }, + proposal: { + connect: { id: proposalId } + }, + vote_type: voteType + }, + include: { + proposal: { + select: { + process_id: true // Include the process_id from the proposal + } + } + } + }); + + await prisma.participation.upsert({ + where: { + user_id_process_id_participation_type: { + user_id: Number(session.user.id), + process_id: vote.proposal.process_id, + participation_type: 'vote', + }, + }, + update: {}, + create: { + user: { + connect: { id: Number(session.user.id) } + }, + process: { + connect: { id: vote.proposal.process_id } + }, + participation_type: 'vote', + }, + }); + + return NextResponse.json( + { message: 'Vote cast successfully', vote: vote.id }, + { status: 201 } + ); + } catch (error) { + console.error('Vote cast error:', error); + return NextResponse.json( + { error: 'Internal server error', fieldErrors: {} }, + { status: 500 } + ); + } +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index f90871a..d0eb904 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -8,107 +8,32 @@ import { Progress } from '@/components/ui/progress' import { MainNav } from '@/components/main-nav' import { LanguageSwitcher } from '@/components/language-switcher' import { Footer } from '@/components/footer' -import { useAuth } from '@/lib/auth-context' +import { useSession, signIn, signOut } from "next-auth/react" import { useLanguage } from '@/components/language-provider' import { supabase, Process, Proposal, Profile } from '@/lib/supabase' import { Vote, Users, Calendar, TrendingUp, FileText, MessageSquare } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' +import { useDashboard } from '@/lib/queries' export default function DashboardPage() { - const { user, loading } = useAuth() + const { data: session, status } = useSession() + const user = session?.user + + const {data: dashboard_data, isPending} = useDashboard() + const { t } = useLanguage() const router = useRouter() - const [profile, setProfile] = useState(null) - const [processes, setProcesses] = useState([]) - const [proposals, setProposals] = useState([]) - const [stats, setStats] = useState({ - totalProcesses: 0, - activeProcesses: 0, - totalProposals: 0, - totalVotes: 0 - }) useEffect(() => { - if (!loading && !user) { + console.log('>>> User in dashboard:', session); + if (status === 'unauthenticated') { router.push('/login') } - }, [user, loading, router]) - - useEffect(() => { - if (user) { - fetchDashboardData() - } - }, [user]) - - const fetchDashboardData = async () => { - if (!user) return - try { - // Fetch profile - const { data: profileData } = await supabase - .from('profiles') - .select('*') - .eq('id', user.id) - .single() - - if (profileData) { - setProfile(profileData) - } - - // Fetch processes - const { data: processesData } = await supabase - .from('processes') - .select('*') - .eq('status', 'active') - .order('created_at', { ascending: false }) - .limit(5) - - if (processesData) { - setProcesses(processesData) - } - - // Fetch user's proposals - const { data: proposalsData } = await supabase - .from('proposals') - .select('*') - .eq('author_id', user?.id) - .order('created_at', { ascending: false }) - .limit(5) - - if (proposalsData) { - setProposals(proposalsData) - } - - // Fetch stats - const { count: totalProcesses } = await supabase - .from('processes') - .select('*', { count: 'exact', head: true }) - - const { count: activeProcesses } = await supabase - .from('processes') - .select('*', { count: 'exact', head: true }) - .eq('status', 'active') - - const { count: totalProposals } = await supabase - .from('proposals') - .select('*', { count: 'exact', head: true }) + }, [status,router]) - const { count: totalVotes } = await supabase - .from('votes') - .select('*', { count: 'exact', head: true }) - setStats({ - totalProcesses: totalProcesses || 0, - activeProcesses: activeProcesses || 0, - totalProposals: totalProposals || 0, - totalVotes: totalVotes || 0 - }) - } catch (error) { - console.error('Error fetching dashboard data:', error) - } - } - - if (loading) { + if (isPending || !dashboard_data) { return (
@@ -119,7 +44,7 @@ export default function DashboardPage() { ) } - if (!user) { + if (!session && isPending) { return null } @@ -144,7 +69,7 @@ export default function DashboardPage() {

Dashboard

-

Welcome back, {profile?.full_name || user.email}

+

Welcome back, {user?.name || user?.email}

{/* Stats */} @@ -155,9 +80,9 @@ export default function DashboardPage() { -
{stats.totalProcesses}
+
{dashboard_data['totalProcesses']}

- {stats.activeProcesses} active + {dashboard_data['activeProcesses']} active

@@ -168,7 +93,7 @@ export default function DashboardPage() { -
{stats.totalProposals}
+
{dashboard_data['totalProposals']}

Community proposals

@@ -181,7 +106,7 @@ export default function DashboardPage() { -
{stats.totalVotes}
+
{dashboard_data['totalVotes']}

Cast by citizens

@@ -194,7 +119,7 @@ export default function DashboardPage() { -
{proposals.length}
+
{dashboard_data['proposals'].length}

Submitted by you

@@ -211,7 +136,10 @@ export default function DashboardPage() {
- {processes.map((process) => ( + {dashboard_data['processes'].length < 1 && !isPending && ( +

No recent processes

+ )} + {dashboard_data['processes'].map((process: any) => (

@@ -224,9 +152,6 @@ export default function DashboardPage() { {process.status}

))} - {processes.length === 0 && ( -

No recent processes

- )}
) -} \ No newline at end of file +} diff --git a/app/layout.tsx b/app/layout.tsx index 8e7c015..788c1f6 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,17 +3,20 @@ import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import './globals.css'; import { LanguageProvider } from '@/components/language-provider'; -import { AuthProvider } from '@/lib/auth-context'; +import AuthProvider from '@/lib/auth-context'; import { Analytics } from '@vercel/analytics/next'; -import { Toaster } from 'sonner'; +import { Toaster } from "@/components/ui/sonner"; +import { ReactQueryClientProvider } from '@/components/react-query-provider'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; const inter = Inter({ subsets: ['latin'] }); export const metadata: Metadata = { - title: "Hum Awaz | Digital Democracy Platform", - description: "A digital democracy platform for civic participation in Pakistan", - generator: 'v0.dev' -} + title: 'Hum Awaz | Digital Democracy Platform', + description: + 'A digital democracy platform for civic participation in Pakistan', + generator: 'v0.dev', +}; export default function RootLayout({ children, @@ -21,22 +24,28 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - - - - {children} - - - - - + + + + + + {children} + + + + + + + ); } diff --git a/app/login/page.tsx b/app/login/page.tsx index 228ebd0..1af89b0 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,40 +1,99 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Alert, AlertDescription } from '@/components/ui/alert' -import { useAuth } from '@/lib/auth-context' import { useLanguage } from '@/components/language-provider' import { Vote } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' +import { useForm } from "react-hook-form" +import { loginFormSchema } from '@/lib/forms' +import { zodResolver } from "@hookform/resolvers/zod" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { signIn, getSession } from "next-auth/react" +import z from 'zod' export default function LoginPage() { - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [loading, setLoading] = useState(false) - const [error, setError] = useState('') - const { signIn } = useAuth() const { t } = useLanguage() const router = useRouter() + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const form = useForm>({ + resolver: zodResolver(loginFormSchema), + defaultValues: { + email: "", + password: "", + }, + }) + + // Check if user is already logged in + useEffect(() => { + const checkSession = async () => { + const session = await getSession() + if (session) { + router.push('/dashboard') // or wherever you want to redirect + } + } + checkSession() + }, [router]) - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() + // In your login page, add this useEffect to debug + useEffect(() => { + const debugSession = async () => { + const session = await getSession() + console.log('Current session:', session) + } + debugSession() + }, []) + + async function onSubmit(values: z.infer) { setLoading(true) setError('') - const { error } = await signIn(email, password) - - if (error) { - setError(error.message) - } else { - router.push('/') + try { + const result = await signIn('credentials', { + email: values.email, + password: values.password, + redirect: true, + }) + + if (result?.error) { + setError('Invalid credentials. Please check your email and password.') + } else if (result?.ok) { + // Successful login - redirect to dashboard or intended page + router.push('/dashboard') + router.refresh() // Refresh to update session state + } + } catch (err) { + setError('An unexpected error occurred. Please try again.') + console.error('Login error:', err) + } finally { + setLoading(false) + } + } + + const handleGoogleSignIn = async () => { + try { + await signIn('google', { + callbackUrl: '/dashboard' // or your preferred redirect URL + }) + } catch (err) { + setError('Failed to sign in with Google') + console.error('Google sign-in error:', err) } - - setLoading(false) } return ( @@ -59,50 +118,100 @@ export default function LoginPage() { -
- {error && ( - - {error} - - )} - -
- - setEmail(e.target.value)} - required - placeholder="Enter your email" - /> -
+ {error && ( + + + {error} + + + )} -
- - setPassword(e.target.value)} - required - placeholder="Enter your password" + {/* Google Sign In Button */} +
+ + + + + Continue with Google + -
- - {t('auth.forgotPassword')} - +
+
+ +
+
+ Or continue with
+
- - +
+ +
+
+ ( + + Email + + + + + + )} + /> +
+
+ ( + +
+ Password +
+ + + + +
+ )} + /> +
+
+ + {t('auth.forgotPassword')} + +
+ +
+
+
diff --git a/app/page.tsx b/app/page.tsx index b379ea9..4404796 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -18,11 +18,13 @@ import { ShareButton } from '@/components/share-button'; import { ChevronRight, Users, BarChart3, Vote } from 'lucide-react'; import Link from 'next/link'; import { useLanguage } from '@/components/language-provider'; -import { useAuth } from '@/lib/auth-context'; +import { useSession, signOut } from 'next-auth/react' export default function Home() { const { t } = useLanguage(); - const { user } = useAuth(); + const { data: session, status } = useSession() + const user = session?.user + const handleLogout = () => signOut() return (
@@ -33,25 +35,25 @@ export default function Home() { Hum Awaz - - -
+ + +
- - {user ? ( + + {user ? ( - ) : ( + Dashboard + + ) : (
- - + +
- )} + )}
diff --git a/app/processes/[id]/page.tsx b/app/processes/[id]/page.tsx index ee03595..b7dfaca 100644 --- a/app/processes/[id]/page.tsx +++ b/app/processes/[id]/page.tsx @@ -12,15 +12,12 @@ import { } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Textarea } from '@/components/ui/textarea'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { MainNav } from '@/components/main-nav'; import { LanguageSwitcher } from '@/components/language-switcher'; import { Footer } from '@/components/footer'; import { ShareButton } from '@/components/share-button'; -import { useAuth } from '@/lib/auth-context'; +import { useSession, signIn, signOut } from "next-auth/react" import { useLanguage } from '@/components/language-provider'; import { supabase, Process, Proposal, Vote, Discussion } from '@/lib/supabase'; import { @@ -38,311 +35,21 @@ import { } from 'lucide-react'; import Link from 'next/link'; +import { useProcessSingle, useProposals } from '@/lib/queries'; +import { NewProposalForm } from '../components/new-proposal-form'; +import { useCastVote } from '@/lib/mutations'; +import { DiscussionForm } from '../components/discussion-form'; +import { CommentForm } from '../components/comment-form'; export default function ProcessDetailPage() { - const params = useParams(); - const router = useRouter(); - const { user } = useAuth(); + const params = useParams() + const process_id = Number(params.id) + const { data: session, status } = useSession() + const user = session?.user const { t, language } = useLanguage(); - const [process, setProcess] = useState(null); - const [proposals, setProposals] = useState([]); - const [discussions, setDiscussions] = useState([]); - const [votes, setVotes] = useState([]); - const [loading, setLoading] = useState(true); - const [newProposal, setNewProposal] = useState({ - title: '', - title_ur: '', - description: '', - description_ur: '', - }); - const [newDiscussion, setNewDiscussion] = useState({ - content: '', - content_ur: '', - }); - const [error, setError] = useState(''); - const [success, setSuccess] = useState(''); - const [votingLoading, setVotingLoading] = useState(null); - const [submittingProposal, setSubmittingProposal] = useState(false); - const [submittingDiscussion, setSubmittingDiscussion] = useState(false); - const [newProposalComment, setNewProposalComment] = useState({ - content: '', - content_ur: '', - }); - const [submittingComment, setSubmittingComment] = useState( - null - ); - - useEffect(() => { - if (params.id) { - fetchProcessData(); - } - }, [params.id]); - - const fetchProcessData = async () => { - try { - const processId = Array.isArray(params.id) ? params.id[0] : params.id; - - // Fetch process - const { data: processData, error: processError } = await supabase - .from('processes') - .select('*') - .eq('id', processId) - .single(); - - if (processError) { - console.error('Error fetching process:', processError); - router.push('/processes'); - return; - } - - setProcess(processData); - - // Fetch proposals - const { data: proposalsData } = await supabase - .from('proposals') - .select('*') - .eq('process_id', processId) - .eq('status', 'approved') - .order('created_at', { ascending: false }); - - setProposals(proposalsData || []); - - // Fetch votes - const { data: votesData } = await supabase - .from('votes') - .select('*') - .in( - 'proposal_id', - (proposalsData || []).map((p) => p.id) - ); - - setVotes(votesData || []); - - // Fetch discussions (both process and proposal discussions) - const proposalIds = (proposalsData || []).map((p) => p.id); - const orCondition = - proposalIds.length > 0 - ? `process_id.eq.${processId},proposal_id.in.(${proposalIds.join( - ',' - )})` - : `process_id.eq.${processId}`; - - const { data: discussionsData, error: discussionsError } = await supabase - .from('discussions') - .select('*') - .or(orCondition) - .eq('is_deleted', false) - .order('created_at', { ascending: true }); - - if (discussionsError) { - console.error('Error fetching discussions:', discussionsError); - } - - console.log('Fetched discussions:', discussionsData); - console.log('OrCondition used:', orCondition); - setDiscussions(discussionsData || []); - - // Track participation - if (user) { - await supabase.from('participations').upsert({ - user_id: user.id, - process_id: processId, - participation_type: 'view', - }); - } - } catch (error) { - console.error('Error:', error); - } finally { - setLoading(false); - } - }; - - const handleSubmitProposal = async (e: React.FormEvent) => { - e.preventDefault(); - if (!user || !process) return; - - setError(''); - setSuccess(''); - setSubmittingProposal(true); - - try { - const { error } = await supabase.from('proposals').insert({ - process_id: process.id, - title: newProposal.title, - title_ur: newProposal.title_ur || null, - description: newProposal.description, - description_ur: newProposal.description_ur || null, - author_id: user.id, - status: 'under_review', - }); - - if (error) { - setError('Failed to submit proposal'); - } else { - setSuccess( - 'Proposal submitted successfully! It will appear publicly after review and approval.' - ); - setNewProposal({ - title: '', - title_ur: '', - description: '', - description_ur: '', - }); - await fetchProcessData(); - - // Track participation - await supabase.from('participations').upsert({ - user_id: user.id, - process_id: process.id, - participation_type: 'proposal', - }); - } - } catch (error) { - setError('An unexpected error occurred'); - } finally { - setSubmittingProposal(false); - } - }; - - const handleVote = async ( - proposalId: string, - voteType: 'support' | 'oppose' | 'neutral' - ) => { - if (!user || !process) return; - - setVotingLoading(proposalId); - - try { - // First, check if user has already voted on this proposal - const { data: existingVote } = await supabase - .from('votes') - .select('id, vote_type') - .eq('proposal_id', proposalId) - .eq('user_id', user.id) - .single(); - - let error = null; - - if (existingVote) { - // User has voted before, update their vote - const { error: updateError } = await supabase - .from('votes') - .update({ vote_type: voteType }) - .eq('id', existingVote.id); - - error = updateError; - } else { - // User hasn't voted, insert new vote - const { error: insertError } = await supabase.from('votes').insert({ - proposal_id: proposalId, - user_id: user.id, - vote_type: voteType, - }); - - error = insertError; - } - - if (!error) { - await fetchProcessData(); - - // Track participation - await supabase.from('participations').upsert({ - user_id: user.id, - process_id: process.id, - participation_type: 'vote', - }); - } else { - console.error('Error voting:', error); - } - } catch (error) { - console.error('Error voting:', error); - } finally { - setVotingLoading(null); - } - }; - - const handleSubmitDiscussion = async () => { - if (!user || !newDiscussion.content || !process) return; - setError(''); - setSubmittingDiscussion(true); - - try { - const { error } = await supabase.from('discussions').insert({ - process_id: process.id, - author_id: user.id, - content: newDiscussion.content, - content_ur: newDiscussion.content_ur || null, - }); - - if (error) { - console.error('Error inserting discussion:', error); - setError('Failed to submit discussion'); - } else { - setNewDiscussion({ content: '', content_ur: '' }); - await fetchProcessData(); - - // Track participation - await supabase.from('participations').upsert({ - user_id: user.id, - process_id: process.id, - participation_type: 'comment', - }); - } - } catch (error) { - console.error('Error submitting discussion:', error); - setError('An unexpected error occurred'); - } finally { - setSubmittingDiscussion(false); - } - }; - - const handleSubmitProposalComment = async (proposalId: string) => { - if (!user || !newProposalComment.content || !process) return; - - setSubmittingComment(proposalId); - - try { - const { error } = await supabase.from('discussions').insert({ - proposal_id: proposalId, - author_id: user.id, - content: newProposalComment.content, - content_ur: newProposalComment.content_ur || null, - }); - - if (!error) { - setNewProposalComment({ content: '', content_ur: '' }); - await fetchProcessData(); - - // Track participation - await supabase.from('participations').upsert({ - user_id: user.id, - process_id: process.id, - participation_type: 'comment', - }); - } - } catch (error) { - console.error('Error submitting comment:', error); - } finally { - setSubmittingComment(null); - } - }; - - const getVoteCount = (proposalId: string, type: string) => { - return votes.filter( - (v) => v.proposal_id === proposalId && v.vote_type === type - ).length; - }; - - const getUserVote = (proposalId: string) => { - return votes.find( - (v) => v.proposal_id === proposalId && v.user_id === user?.id - ); - }; - - const getProposalDiscussions = (proposalId: string) => { - return discussions.filter((d) => d.proposal_id === proposalId); - }; + const {data: process_data, isPending: isPendingProcess} = useProcessSingle(process_id) + const useCastVoteMutation = useCastVote(); const getTitle = (item: { title: string; title_ur?: string }) => { return language === 'ur' && item.title_ur ? item.title_ur : item.title; @@ -363,7 +70,7 @@ export default function ProcessDetailPage() { : item.content; }; - if (loading) { + if (isPendingProcess) { return (
@@ -374,7 +81,7 @@ export default function ProcessDetailPage() { ); } - if (!process) { + if (!process_data) { return (
@@ -433,52 +140,52 @@ export default function ProcessDetailPage() {
- {t(`status.${process.status}`)} + {t(`status.${process_data.status}`)} - {t(`category.${process.category.toLowerCase()}`)} + {t(`category.${process_data.category.toLowerCase()}`)}
-

{getTitle(process)}

+

{getTitle(process_data)}

- {getDescription(process)} + {getDescription(process_data)}

- {process.organization || 'Government Initiative'} + {process_data.organization || 'Government Initiative'}
- Ends: {new Date(process.end_date).toLocaleDateString()} + Ends: {new Date(process_data.end_date).toLocaleDateString()}
- {process.participation_count || 0} participants + {process_data.participation_count || 0} participants
@@ -492,14 +199,14 @@ export default function ProcessDetailPage() { className="flex items-center gap-2" > - Proposals ({proposals.length}) + Proposals ({process_data.proposals.length}) - Discussions ({discussions.length}) + Discussions ({process_data.discussions.length}) @@ -508,7 +215,7 @@ export default function ProcessDetailPage() { - {proposals.length === 0 ? ( + {process_data.proposals.length === 0 ? ( @@ -518,7 +225,7 @@ export default function ProcessDetailPage() { ) : ( - proposals.map((proposal) => ( + process_data.proposals.map((proposal:any) => ( @@ -528,54 +235,54 @@ export default function ProcessDetailPage() { <> )} @@ -593,11 +300,11 @@ export default function ProcessDetailPage() {

Discussions ( - {getProposalDiscussions(proposal.id).length}) + {proposal.discussions.length})

- {getProposalDiscussions(proposal.id).map( - (discussion) => ( + {proposal.discussions.map( + (discussion: any) => (
-