diff --git a/app/app/api/comments/[id]/route.ts b/app/app/api/comments/[id]/route.ts new file mode 100644 index 0000000..6a922b2 --- /dev/null +++ b/app/app/api/comments/[id]/route.ts @@ -0,0 +1,36 @@ +import { NextRequest } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { apiSuccess, apiError } from '@/lib/api-response'; +import { getCurrentUser } from '@/lib/auth'; + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const currentUser = await getCurrentUser(request); + if (!currentUser) return apiError('Unauthorized', 401); + + const { id } = await params; + + const comment = await prisma.comment.findUnique({ + where: { id } + }); + + if (!comment) { + return apiError('Comment not found', 404); + } + + if (comment.userId !== currentUser.id) { + return apiError('Forbidden', 403); + } + + await prisma.comment.delete({ + where: { id } + }); + + return apiSuccess({ success: true }); + } catch (error) { + return apiError('Failed to delete comment', 500); + } +} diff --git a/app/app/api/posts/[id]/comments/route.ts b/app/app/api/posts/[id]/comments/route.ts new file mode 100644 index 0000000..6b6e29d --- /dev/null +++ b/app/app/api/posts/[id]/comments/route.ts @@ -0,0 +1,99 @@ +import { NextRequest } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { apiSuccess, apiError } from '@/lib/api-response'; +import { getCurrentUser } from '@/lib/auth'; +import { readJsonBody } from '@/lib/parse-json-body'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const limit = parseInt(searchParams.get('limit') || '50'); + const skip = (page - 1) * limit; + + const [comments, total] = await Promise.all([ + prisma.comment.findMany({ + where: { postId: id, parentId: null }, + include: { + user: { + select: { id: true, name: true, avatarUrl: true, rank: true } + }, + replies: { + include: { + user: { + select: { id: true, name: true, avatarUrl: true, rank: true } + } + }, + orderBy: { createdAt: 'asc' } + } + }, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + prisma.comment.count({ + where: { postId: id, parentId: null } + }) + ]); + + return apiSuccess({ + comments, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit) + } + }); + } catch (error) { + return apiError('Failed to fetch comments', 500); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const currentUser = await getCurrentUser(request); + if (!currentUser) return apiError('Unauthorized', 401); + + const { id } = await params; + const body = await readJsonBody(request); + if (!body.ok) return body.response; + + const { content, parentId, entryId } = body.data as any; + + if (!content || typeof content !== 'string') { + return apiError('Content is required', 400); + } + + const comment = await prisma.comment.create({ + data: { + content, + postId: id, + userId: currentUser.id, + parentId: parentId || null, + entryId: entryId || null, + }, + include: { + user: { + select: { id: true, name: true, avatarUrl: true, rank: true } + }, + replies: { + include: { + user: true + } + } + } + }); + + return apiSuccess(comment); + } catch (error) { + return apiError('Failed to create comment', 500); + } +} diff --git a/app/app/api/posts/[id]/contributions/route.ts b/app/app/api/posts/[id]/contributions/route.ts new file mode 100644 index 0000000..360f84e --- /dev/null +++ b/app/app/api/posts/[id]/contributions/route.ts @@ -0,0 +1,163 @@ +import { NextRequest } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { apiSuccess, apiError } from '@/lib/api-response'; +import { getCurrentUser } from '@/lib/auth'; +import { readJsonBody } from '@/lib/parse-json-body'; +import { XP_REWARDS, awardXp } from '@/lib/xp'; +import { checkAndAwardBadges } from '@/lib/badges'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const currentUser = await getCurrentUser(request); + if (!currentUser) return apiError('Unauthorized', 401); + + const { id: postId } = await params; + const body = await readJsonBody>(request); + if (!body.ok) return body.response; + + const { amount, message, isAnonymous } = body.data as any; + const parsedAmount = Number(amount); + + if (isNaN(parsedAmount) || parsedAmount <= 0) { + return apiError('Amount must be greater than 0', 400); + } + + const post = await prisma.post.findUnique({ + where: { id: postId }, + select: { id: true, type: true, targetAmount: true, status: true, userId: true }, + }); + + if (!post) { + return apiError('Post not found', 404); + } + + if (post.type !== 'request') { + return apiError('Contributions can only be made to help requests', 400); + } + + if (!['open', 'in_progress', 'active'].includes(post.status)) { + return apiError('Help request is not accepting contributions', 400); + } + + const contribution = await prisma.$transaction(async (tx) => { + const created = await tx.helpContribution.create({ + data: { + postId, + userId: currentUser.id, + amount: parsedAmount, + message: message || null, + isAnonymous: !!isAnonymous, + contributedAt: new Date(), + }, + include: { + user: { + select: { id: true, name: true, avatarUrl: true, rank: true } + } + } + }); + + await awardXp( + currentUser.id, + XP_REWARDS.contributeToHelpRequest, + 'help_request_contributed', + { metadata: { postId, contributionId: created.id } }, + tx + ); + + // Award the creator for receiving a contribution + await awardXp( + post.userId, + XP_REWARDS.receiveContribution, + 'help_request_received_contribution', + { metadata: { postId, contributionId: created.id } }, + tx + ); + + // Optional: notification to creator + if (post.userId !== currentUser.id && tx.notification?.create) { + try { + await tx.notification.create({ + data: { + userId: post.userId, + type: 'help_contribution', + message: `${isAnonymous ? 'Someone' : currentUser.name} contributed to your help request`, + link: `/post/${postId}`, + } + }); + } catch(e) { /* ignore if table missing */ } + } + + return created; + }); + + // Check badges + checkAndAwardBadges(currentUser.id).catch(console.error); + + // Calculate updated progress + const aggregate = await prisma.helpContribution.aggregate({ + where: { postId }, + _sum: { amount: true } + }); + const totalRaised = aggregate._sum.amount || 0; + + return apiSuccess({ + contribution, + progress: { + totalRaised, + targetAmount: post.targetAmount, + percentage: post.targetAmount ? Math.min(100, Math.round((totalRaised / post.targetAmount) * 100)) : 0 + } + }, 'Contribution successful', 201); + } catch (error) { + console.error('Contribution error:', error); + return apiError('Failed to process contribution', 500); + } +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id: postId } = await params; + + const post = await prisma.post.findUnique({ + where: { id: postId }, + select: { id: true } + }); + + if (!post) { + return apiError('Post not found', 404); + } + + const contributions = await prisma.helpContribution.findMany({ + where: { postId }, + include: { + user: { + select: { id: true, name: true, avatarUrl: true, rank: true } + } + }, + orderBy: { contributedAt: 'desc' } + }); + + const aggregate = await prisma.helpContribution.aggregate({ + where: { postId }, + _sum: { amount: true } + }); + const totalRaised = aggregate._sum.amount || 0; + + return apiSuccess({ + contributions: contributions.map(c => ({ + ...c, + user: c.isAnonymous ? { id: 'anonymous', name: 'Anonymous', avatarUrl: null, rank: null } : c.user + })), + totalRaised + }); + } catch (error) { + console.error('Fetch contributions error:', error); + return apiError('Failed to fetch contributions', 500); + } +} diff --git a/app/app/api/posts/[id]/entries/route.ts b/app/app/api/posts/[id]/entries/route.ts index 027e930..cb9ab49 100644 --- a/app/app/api/posts/[id]/entries/route.ts +++ b/app/app/api/posts/[id]/entries/route.ts @@ -5,6 +5,7 @@ import { NextRequest } from 'next/server'; import { getCurrentUser } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { readJsonBody } from '@/lib/parse-json-body'; +import { checkAndAwardBadges } from '@/lib/badges'; /** * POST /api/posts/[id]/entries @@ -129,6 +130,8 @@ export async function POST ( return createdEntry; }); + checkAndAwardBadges(user.id).catch(console.error); + return apiSuccess(entry, 'Entry created successfully', 201); } catch (error) { console.error('Error creating entry:', error); diff --git a/app/app/api/posts/[id]/route.ts b/app/app/api/posts/[id]/route.ts index f5de40d..f147d6c 100644 --- a/app/app/api/posts/[id]/route.ts +++ b/app/app/api/posts/[id]/route.ts @@ -51,6 +51,18 @@ const GET = async ( } } }, + contributions: { + include: { + user: { + select: { + id: true, + name: true, + avatarUrl: true, + username: true, + } + } + } + }, }, }); @@ -58,7 +70,9 @@ const GET = async ( return apiError('Post not found', 404); } - return apiSuccess(post); + const currentAmount = post.contributions?.reduce((sum, c) => sum + c.amount, 0) || 0; + + return apiSuccess({ ...post, currentAmount }); } catch (error) { return apiError('Failed to fetch post', 500); } diff --git a/app/app/api/posts/[id]/select-winners/route.ts b/app/app/api/posts/[id]/select-winners/route.ts index 7e6b4d2..11e5661 100644 --- a/app/app/api/posts/[id]/select-winners/route.ts +++ b/app/app/api/posts/[id]/select-winners/route.ts @@ -3,6 +3,7 @@ import { NextRequest } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { readJsonBody } from "@/lib/parse-json-body"; +import { checkAndAwardBadges } from "@/lib/badges"; export const POST = async ( request: NextRequest, @@ -97,6 +98,10 @@ export const POST = async ( const selectedUsers = selectedEntries.map(e => e.userId); + for (const winnerId of selectedUsers) { + checkAndAwardBadges(winnerId).catch(console.error); + } + return apiSuccess({ message: 'Winners selected successfully', winners: selectedUsers diff --git a/app/app/api/posts/route.ts b/app/app/api/posts/route.ts index b0d163d..51ba0e4 100644 --- a/app/app/api/posts/route.ts +++ b/app/app/api/posts/route.ts @@ -8,6 +8,7 @@ import { getCurrentUser } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { readJsonBody } from "@/lib/parse-json-body"; import { POST_SLUG_MAX_LENGTH, sanitizePostSlug } from "@/lib/post-slug"; +import { checkAndAwardBadges } from "@/lib/badges"; const SLUG_SUFFIX_LENGTH = 6; @@ -174,6 +175,8 @@ const POST = async (request: NextRequest) => { return createdPost; }); + checkAndAwardBadges(user.id).catch(console.error); + return apiSuccess(post, "Post created successfully", 201); } catch (error) { if ( diff --git a/app/app/api/users/[id]/follow/route.ts b/app/app/api/users/[id]/follow/route.ts index 5354cb0..09664c0 100644 --- a/app/app/api/users/[id]/follow/route.ts +++ b/app/app/api/users/[id]/follow/route.ts @@ -2,6 +2,7 @@ import { NextRequest } from 'next/server'; import { prisma } from '@/lib/prisma'; import { apiSuccess, apiError } from '@/lib/api-response'; import { getCurrentUser } from '@/lib/auth'; +import { checkAndAwardBadges } from '@/lib/badges'; export async function POST( request: NextRequest, @@ -43,6 +44,7 @@ export async function POST( followingId: targetUserId, } }); + checkAndAwardBadges(targetUserId).catch(console.error); return apiSuccess({ success: true, follow }, undefined, 201); } diff --git a/app/app/api/users/[id]/route.ts b/app/app/api/users/[id]/route.ts index e08c383..80cbbfc 100644 --- a/app/app/api/users/[id]/route.ts +++ b/app/app/api/users/[id]/route.ts @@ -33,6 +33,9 @@ export async function GET( followers: true, followings: true, } + }, + badges: { + include: { badge: true } } }, }); @@ -51,7 +54,15 @@ export async function GET( isFollowing = !!follow; } - return apiSuccess({ ...user, isFollowing }); + const normalizedUser = { + ...user, + badges: (user.badges || []).map((userBadge: any) => ({ + ...userBadge.badge, + awardedAt: userBadge.awardedAt, + })), + }; + + return apiSuccess({ ...normalizedUser, isFollowing }); } } catch (dbError) { // Database error - fallback already handled above diff --git a/app/components/comments-section.tsx b/app/components/comments-section.tsx index 39e7a34..97130d8 100644 --- a/app/components/comments-section.tsx +++ b/app/components/comments-section.tsx @@ -1,25 +1,15 @@ 'use client'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { - ExternalLink, - Flame, - ImageIcon, - Send, - Trophy, - Users, -} from 'lucide-react'; -import type { Post, Reply } from '@/lib/types'; - -import { Badge } from '@/components/ui/badge'; +import { Flame, MessageSquare, Send } from 'lucide-react'; +import type { Post, Comment } from '@/lib/types'; import { Button } from '@/components/ui/button'; import Link from 'next/link'; -import type React from 'react'; +import React, { useState, useEffect } from 'react'; import { Textarea } from '@/components/ui/textarea'; import { UserRankBadge } from '@/components/user-rank-badge'; import { useAppContext } from '@/contexts/app-context'; import { useRouter } from 'next/navigation'; -import { useState } from 'react'; interface CommentsSectionProps { post: Post; @@ -28,62 +18,65 @@ interface CommentsSectionProps { } interface ReplyFormProps { - parentId: string; - parentType: 'entry' | 'contribution'; - onSubmit: (content: string) => void; - onCancel: () => void; + parentId?: string; + onSubmit: (content: string) => Promise; + onCancel?: () => void; + placeholder?: string; } function ReplyForm({ parentId, - parentType, onSubmit, onCancel, + placeholder = 'Write a comment...' }: ReplyFormProps) { const [content, setContent] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); const { user } = useAppContext(); - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (content.trim()) { - onSubmit(content.trim()); - setContent(''); + if (content.trim() && !isSubmitting) { + setIsSubmitting(true); + try { + await onSubmit(content.trim()); + setContent(''); + } finally { + setIsSubmitting(false); + } } }; if (!user) return null; return ( -
+
- - + + - {user.name - .split(' ') - .map((n) => n[0]) - .join('')} + {user.name.split(' ').map((n) => n[0]).join('')}