diff --git a/app/app/api/cron/expire-posts/route.ts b/app/app/api/cron/expire-posts/route.ts new file mode 100644 index 0000000..d9c062a --- /dev/null +++ b/app/app/api/cron/expire-posts/route.ts @@ -0,0 +1,66 @@ +import { apiError, apiSuccess } from "@/lib/api-response"; +import { createNotification } from "@/lib/notifications"; +import { prisma } from "@/lib/prisma"; +import { NextRequest } from "next/server"; + +function isAuthorizedCron(request: NextRequest): boolean { + if (request.headers.get("x-vercel-cron") === "1") { + return true; + } + const secret = process.env.CRON_SECRET; + if (!secret) { + return false; + } + const auth = request.headers.get("authorization"); + return auth === `Bearer ${secret}`; +} + +/** + * Hourly job: mark open giveaways whose deadline has passed as expired, + * and notify creators. + */ +const GET = async (request: NextRequest) => { + if (!isAuthorizedCron(request)) { + return apiError("Unauthorized", 401); + } + + const now = new Date(); + + const toExpire = await prisma.post.findMany({ + where: { + status: "open", + endsAt: { lt: now }, + }, + select: { id: true, userId: true, title: true }, + }); + + if (toExpire.length === 0) { + return apiSuccess({ expired: 0, ids: [] as string[] }); + } + + await prisma.post.updateMany({ + where: { + status: "open", + endsAt: { lt: now }, + }, + data: { status: "expired" }, + }); + + await Promise.all( + toExpire.map((post) => + createNotification({ + userId: post.userId, + type: "post_closed", + message: `Your giveaway "${post.title}" has ended (deadline passed).`, + link: `/post/${post.id}`, + }).catch(() => undefined), + ), + ); + + return apiSuccess({ + expired: toExpire.length, + ids: toExpire.map((p) => p.id), + }); +}; + +export { GET }; diff --git a/app/app/api/posts/[id]/entries/route.ts b/app/app/api/posts/[id]/entries/route.ts index cb9ab49..e6576f9 100644 --- a/app/app/api/posts/[id]/entries/route.ts +++ b/app/app/api/posts/[id]/entries/route.ts @@ -1,12 +1,75 @@ import { XP_REWARDS, awardXp } from '@/lib/xp'; import { apiError, apiSuccess } from '@/lib/api-response'; +import { Prisma } from '@prisma/client'; 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'; +function hasEntryProof (proofUrl: unknown, proofImage: unknown): boolean { + const urlOk = typeof proofUrl === 'string' && proofUrl.trim().length > 0; + const imgOk = typeof proofImage === 'string' && proofImage.trim().length > 0; + return urlOk || imgOk; +} + +function txError (message: string, status: number) { + return Object.assign(new Error(message), { httpStatus: status }); +} + +async function notifyGiveawayEntry ( + tx: Prisma.TransactionClient, + postOwnerId: string, + entrantName: string, + postId: string, +) { + try { + await tx.notification.create({ + data: { + userId: postOwnerId, + type: 'giveaway_entry', + message: `${entrantName} entered your giveaway`, + link: `/post/${postId}`, + }, + }); + } catch (err: unknown) { + const code = (err as { code?: string })?.code; + const modelName = String((err as { meta?: { modelName?: string } })?.meta?.modelName || '').toLowerCase(); + if (code === 'P2021' && modelName === 'notification') { + return; + } + throw err; + } +} + +async function notifyGiveawayWins ( + tx: Prisma.TransactionClient, + userIds: string[], + postTitle: string, + postId: string, +) { + if (userIds.length === 0) return; + try { + await tx.notification.createMany({ + data: userIds.map((userId) => ({ + userId, + type: 'giveaway_win' as const, + message: `Congratulations! You won the giveaway "${postTitle}".`, + link: `/post/${postId}`, + })), + skipDuplicates: true, + }); + } catch (err: unknown) { + const code = (err as { code?: string })?.code; + const modelName = String((err as { meta?: { modelName?: string } })?.meta?.modelName || '').toLowerCase(); + if (code === 'P2021' && modelName === 'notification') { + return; + } + throw err; + } +} + /** * POST /api/posts/[id]/entries * Submit an entry to a giveaway post @@ -23,9 +86,12 @@ export async function POST ( const raw = await readJsonBody>(request); if (!raw.ok) return raw.response; const body = raw.data; - const { content, proofUrl } = body as { content?: unknown; proofUrl?: unknown }; + const { content, proofUrl, proofImage } = body as { + content?: unknown; + proofUrl?: unknown; + proofImage?: unknown; + }; - // Validate content if (!content || typeof content !== 'string') { return apiError('Content is required', 400); } @@ -34,105 +100,196 @@ export async function POST ( return apiError('Content must be between 10 and 5000 characters', 400); } - // Check if post exists and is open - const post = await prisma.post.findUnique({ - where: { id: postId }, - select: { id: true, status: true, userId: true, type: true }, - }); + let createdEntry: Awaited>; + let newlyWinningUserIds: string[] = []; - if (!post) { - return apiError('Post not found', 404); - } + try { + const txResult = await prisma.$transaction(async (tx) => { + let newlyWinning: string[] = []; + const post = await tx.post.findUnique({ + where: { id: postId }, + include: { + requirements: { select: { proofRequired: true } }, + winners: { select: { userId: true } }, + }, + }); - if (post.type !== 'giveaway') { - return apiError('Entries can only be submitted to giveaway posts', 400); - } + if (!post) { + throw txError('Post not found', 404); + } - if (post.status !== 'open') { - return apiError('Post is not accepting entries', 400); - } + if (post.type !== 'giveaway') { + throw txError('Entries can only be submitted to giveaway posts', 400); + } - // Prevent creators from entering their own posts - if (post.userId === user.id) { - return apiError('You cannot enter your own giveaway', 403); - } + if (post.status !== 'open') { + throw txError('Post is not accepting entries', 400); + } - // Check for existing entry (unique constraint will catch this too, but we provide better error message) - const existingEntry = await prisma.entry.findUnique({ - where: { - postId_userId: { - postId, - userId: user.id, - }, - }, - }); + if (post.userId === user.id) { + throw txError('You cannot enter your own giveaway', 403); + } - if (existingEntry) { - return apiError('You have already entered this giveaway', 400); - } + const proofRequired = + Boolean(post.proofRequired) || + Boolean(post.requirements?.proofRequired); + if (proofRequired && !hasEntryProof(proofUrl, proofImage)) { + throw txError('Proof is required for this giveaway (provide proofUrl or proofImage)', 400); + } - // Create entry - const entry = await prisma.$transaction(async (tx) => { - const createdEntry = await tx.entry.create({ - data: { - postId, - userId: user.id, - content, - proofUrl: proofUrl || null, - }, - include: { - user: { - select: { - id: true, - name: true, - walletAddress: true, - avatarUrl: true, + const existingEntry = await tx.entry.findUnique({ + where: { + postId_userId: { + postId, + userId: user.id, }, }, - }, - }); + }); + + if (existingEntry) { + throw txError('You have already entered this giveaway', 400); + } - await awardXp( - user.id, - XP_REWARDS.enterGiveaway, - 'giveaway_entered', - { - metadata: { + const maxWinners = post.maxWinners ?? 1; + + if (post.selectionMethod === 'firstcome') { + const entryCount = await tx.entry.count({ where: { postId } }); + if (entryCount >= maxWinners) { + throw txError('This giveaway has filled all winner slots', 400); + } + } + + const proofUrlStr = + typeof proofUrl === 'string' && proofUrl.trim().length > 0 + ? proofUrl.trim() + : null; + const proofImageStr = + typeof proofImage === 'string' && proofImage.trim().length > 0 + ? proofImage.trim() + : null; + + const newEntry = await tx.entry.create({ + data: { postId, - entryId: createdEntry.id, + userId: user.id, + content, + proofUrl: proofUrlStr, + proofImage: proofImageStr, }, - }, - tx, - ); - - // Notify post owner, but ignore if notifications support is unavailable - if (post.userId && tx.notification?.create) { - try { - await tx.notification.create({ - data: { - userId: post.userId, - type: 'giveaway_entry', - message: `${user.name} entered your giveaway`, - link: `/post/${postId}`, + include: { + user: { + select: { + id: true, + name: true, + walletAddress: true, + avatarUrl: true, + }, + }, + }, + }); + + await awardXp( + user.id, + XP_REWARDS.enterGiveaway, + 'giveaway_entered', + { + metadata: { + postId, + entryId: newEntry.id, }, + }, + tx, + ); + + await notifyGiveawayEntry(tx, post.userId, user.name, postId); + + if (post.selectionMethod === 'firstcome') { + const priorWinnerUserIds = new Set(post.winners.map((w) => w.userId)); + + const orderedEntries = await tx.entry.findMany({ + where: { postId }, + orderBy: { createdAt: 'asc' }, + }); + + const top = orderedEntries.slice(0, maxWinners); + const topIds = top.map((e) => e.id); + + if (topIds.length > 0) { + await tx.entry.updateMany({ + where: { postId, id: { in: topIds } }, + data: { isWinner: true }, + }); + } + + const nonTopIds = orderedEntries + .filter((e) => !topIds.includes(e.id)) + .map((e) => e.id); + + if (nonTopIds.length > 0) { + await tx.entry.updateMany({ + where: { postId, id: { in: nonTopIds } }, + data: { isWinner: false }, + }); + } + + await tx.postWinner.createMany({ + data: top.map((e) => ({ + postId, + userId: e.userId, + assignedBy: post.userId, + })), + skipDuplicates: true, }); - } catch (err: any) { - if (err?.code === 'P2021' && String(err?.meta?.modelName).toLowerCase() === 'notification') { - // Table does not exist, skip notification - // Optionally log: console.warn('Notification table missing, skipping notification.'); - } else { - throw err; + + newlyWinning = top + .map((e) => e.userId) + .filter((uid) => !priorWinnerUserIds.has(uid)); + + await notifyGiveawayWins(tx, newlyWinning, post.title, postId); + + if (orderedEntries.length >= maxWinners) { + await tx.post.update({ + where: { id: postId }, + data: { status: 'completed' }, + }); } } - } - return createdEntry; - }); + return { newEntry, newlyWinning }; + }, { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + maxWait: 5000, + timeout: 10000, + }); + createdEntry = txResult.newEntry; + newlyWinningUserIds = txResult.newlyWinning; + } catch (error: unknown) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + return apiError('You have already entered this giveaway', 400); + } + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2034' + ) { + return apiError('Could not submit entry, please try again', 409); + } + const httpStatus = (error as { httpStatus?: number }).httpStatus; + if (typeof httpStatus === 'number' && error instanceof Error) { + return apiError(error.message, httpStatus); + } + throw error; + } checkAndAwardBadges(user.id).catch(console.error); + for (const uid of newlyWinningUserIds) { + checkAndAwardBadges(uid).catch(console.error); + } - return apiSuccess(entry, 'Entry created successfully', 201); + return apiSuccess(createdEntry, 'Entry created successfully', 201); } catch (error) { console.error('Error creating entry:', error); return apiError('Failed to create entry', 500); diff --git a/app/app/api/posts/route.ts b/app/app/api/posts/route.ts index 6b64873..720342d 100644 --- a/app/app/api/posts/route.ts +++ b/app/app/api/posts/route.ts @@ -201,11 +201,16 @@ const GET = async (request: NextRequest) => { const q = searchParams.get("q"); const category = searchParams.get("category"); const sort = searchParams.get("sort"); + const filter = searchParams.get("filter"); const page = parseInt(searchParams.get("page") || "1"); const limit = parseInt(searchParams.get("limit") || "10"); const where: any = {}; + if (filter === "active") { + where.endsAt = { gt: new Date() }; + } + if (q) { where.OR = [ { title: { contains: q, mode: "insensitive" } }, diff --git a/app/app/api/users/[id]/posts/route.ts b/app/app/api/users/[id]/posts/route.ts index fa3010f..5b9779a 100644 --- a/app/app/api/users/[id]/posts/route.ts +++ b/app/app/api/users/[id]/posts/route.ts @@ -22,11 +22,30 @@ export async function GET ( return apiError('User not found', 404); } - // Fetch user's posts + // Fetch user's posts (shape aligned with feed/detail for PostCard) const userPosts = await prisma.post.findMany({ where: { userId: id }, orderBy: { createdAt: 'desc' }, include: { + user: { + select: { + id: true, + name: true, + avatarUrl: true, + username: true, + rank: { + select: { + id: true, + level: true, + title: true, + color: true, + minPoints: true, + maxPoints: true, + }, + }, + }, + }, + media: true, entries: { select: { id: true, @@ -36,6 +55,13 @@ export async function GET ( createdAt: true, }, }, + _count: { + select: { + entries: true, + interactions: true, + comments: true, + }, + }, }, }); diff --git a/app/app/post/[postId]/page.tsx b/app/app/post/[postId]/page.tsx index 5488dda..d82d231 100644 --- a/app/app/post/[postId]/page.tsx +++ b/app/app/post/[postId]/page.tsx @@ -7,7 +7,6 @@ import { ChevronLeft, ChevronRight, ChevronUp, - Clock, DollarSign, Flame, Gift, @@ -24,15 +23,16 @@ import { Button } from "@/components/ui/button"; import { CommentsSection } from "@/components/comments-section"; import { ContributionForm } from "@/components/contribution-form"; import { EntryForm } from "@/components/entry-form"; +import { GiveawayCountdown } from "@/components/giveaway-countdown"; import Link from "next/link"; import { Progress } from "@/components/ui/progress"; import { UserRankBadge } from "@/components/user-rank-badge"; import { useAppContext } from "@/contexts/app-context"; -import { useEffect, useState } from "react"; +import { mapApiPostToClientPost } from "@/lib/map-api-post"; +import { useCallback, useEffect, useState } from "react"; export default function PostPage() { - const { user, burnPost } = useAppContext(); - const [post, setPost] = useState(null); + const { user, burnPost, posts, refreshPostDetail } = useAppContext(); const params = useParams(); const postId = params.postId as string; const contextPost = posts.find((p) => p.id === postId); @@ -47,79 +47,65 @@ export default function PostPage() { const [showContributionForm, setShowContributionForm] = useState(false); const router = useRouter(); - const normalizeApiPost = (apiPost: any) => { - if (!apiPost) return null; - - const normalizedType = apiPost.type; - const normalizedStatus = - apiPost.status === "open" || apiPost.status === "in_progress" - ? "active" - : apiPost.status; - const fallbackUsername = - apiPost.user?.name?.toLowerCase()?.replace(/\s+/g, "") || "user"; - - return { - ...apiPost, - type: normalizedType, - status: normalizedStatus, - createdAt: apiPost.createdAt ? new Date(apiPost.createdAt) : new Date(), - updatedAt: apiPost.updatedAt ? new Date(apiPost.updatedAt) : new Date(), - endDate: apiPost.endsAt ? new Date(apiPost.endsAt) : undefined, - burnCount: apiPost.burnCount ?? 0, - shareCount: apiPost.shareCount ?? 0, - commentCount: apiPost.commentCount ?? 0, - likesCount: apiPost.likesCount ?? apiPost._count?.interactions ?? 0, - entriesCount: apiPost.entriesCount ?? apiPost._count?.entries ?? 0, - author: { - id: apiPost.user?.id ?? apiPost.userId, - name: apiPost.user?.name ?? "Unknown User", - username: apiPost.user?.username ?? fallbackUsername, - avatarUrl: apiPost.user?.avatarUrl, - rank: apiPost.user?.rank, - }, - }; - }; - useEffect(() => { - let ignore = false; + const reloadPostFromApi = useCallback(async () => { + setIsLoadingPost(true); + try { + const response = await fetch(`/api/posts/${postId}`, { + cache: "no-store", + }); - const loadPost = async () => { - if (contextPost) { + if (!response.ok) { setFetchedPost(null); - setIsLoadingPost(false); return; } - setIsLoadingPost(true); + const result = await response.json(); + setFetchedPost( + mapApiPostToClientPost(result?.data as Record), + ); + } catch { + setFetchedPost(null); + } finally { + setIsLoadingPost(false); + } + }, [postId]); + useEffect(() => { + if (contextPost) { + setFetchedPost(null); + setIsLoadingPost(false); + return; + } + + let ignore = false; + + const run = async () => { + setIsLoadingPost(true); try { const response = await fetch(`/api/posts/${postId}`, { cache: "no-store", }); if (!response.ok) { - if (!ignore) { - setFetchedPost(null); - } + if (!ignore) setFetchedPost(null); return; } const result = await response.json(); if (!ignore) { - setFetchedPost(normalizeApiPost(result?.data)); - } - } catch (error) { - if (!ignore) { - setFetchedPost(null); + setFetchedPost( + mapApiPostToClientPost(result?.data as Record), + ); } + } catch { + if (!ignore) setFetchedPost(null); } finally { - if (!ignore) { - setIsLoadingPost(false); - } + if (!ignore) setIsLoadingPost(false); } }; - loadPost(); + void run(); return () => { ignore = true; @@ -142,7 +128,8 @@ export default function PostPage() { body: JSON.stringify({ method, winnerIds }) }); if (res.ok) { - loadPost(); + await refreshPostDetail(post.id); + if (!contextPost) await reloadPostFromApi(); } else { const errorData = await res.json(); alert(errorData.error || 'Failed to select winners'); @@ -418,10 +405,7 @@ export default function PostPage() { {post.endDate && ( -
- - Ends {post.endDate.toLocaleDateString()} -
+ )} diff --git a/app/app/profile/[userId]/page.tsx b/app/app/profile/[userId]/page.tsx index 1b1f7ef..291bea3 100644 --- a/app/app/profile/[userId]/page.tsx +++ b/app/app/profile/[userId]/page.tsx @@ -13,6 +13,8 @@ import { FollowListDialog } from '@/components/follow-list-dialog'; import { PostCard } from '@/components/post-card'; import { UserRankBadge } from '@/components/user-rank-badge'; import { useAppContext } from '@/contexts/app-context'; +import { mapApiPostToClientPost } from '@/lib/map-api-post'; +import type { Post } from '@/lib/types'; import { useParams } from 'next/navigation'; import { useState, useEffect } from 'react'; @@ -20,7 +22,7 @@ export default function ProfilePage() { const params = useParams(); const { user: currentUser } = useAppContext(); const [profileUser, setProfileUser] = useState(null); - const [userPosts, setUserPosts] = useState([]); + const [userPosts, setUserPosts] = useState([]); const [showAchievements, setShowAchievements] = useState(false); const [isFollowing, setIsFollowing] = useState(false); const [followerCount, setFollowerCount] = useState(0); @@ -42,8 +44,8 @@ export default function ProfilePage() { const loadProfile = async () => { try { const [userRes, postsRes] = await Promise.all([ - fetch(`/api/users/${userId}`), - fetch(`/api/posts?userId=${userId}`), + fetch(`/api/users/${userId}`, { cache: 'no-store' }), + fetch(`/api/users/${userId}/posts`, { cache: 'no-store' }), ]); if (userRes.ok) { @@ -55,8 +57,14 @@ export default function ProfilePage() { } if (postsRes.ok) { - const postsData = await postsRes.json(); - setUserPosts(postsData.data ?? []); + const postsData = await postsRes.json(); + const rawList = Array.isArray(postsData.data) ? postsData.data : []; + const mapped = rawList + .map((p: Record) => mapApiPostToClientPost(p)) + .filter(Boolean) as Post[]; + setUserPosts(mapped); + } else { + setUserPosts([]); } } catch (error) { console.error('Failed to load profile:', error); @@ -82,7 +90,9 @@ export default function ProfilePage() { setIsFollowing(!newIsFollowing); setFollowerCount((prev) => (newIsFollowing ? prev - 1 : prev + 1)); } - }; if (!profileUser) { + }; + + if (!profileUser) { return (
diff --git a/app/components/create-giveaway-modal.tsx b/app/components/create-giveaway-modal.tsx index e32867f..5d2cb52 100644 --- a/app/components/create-giveaway-modal.tsx +++ b/app/components/create-giveaway-modal.tsx @@ -93,14 +93,17 @@ const response = await fetch("/api/posts", { type: "giveaway", title: formData.title, description: formData.description, - status: "active", prizeAmount: Number.parseFloat(formData.prizeAmount), currency: formData.currency, winnerCount: Number.parseInt(formData.winnerCount), selectionType: formData.selectionType, entryRequirements: requirements, proofRequired: formData.proofRequired, - endDate: formData.endDate ? new Date(formData.endDate) : undefined, + endsAt: ( + formData.endDate + ? new Date(formData.endDate) + : new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) + ).toISOString(), media: media.length > 0 ? media : undefined, }), }); diff --git a/app/components/giveaway-countdown.tsx b/app/components/giveaway-countdown.tsx new file mode 100644 index 0000000..35c4d32 --- /dev/null +++ b/app/components/giveaway-countdown.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { Clock } from "lucide-react"; +import { useEffect, useState } from "react"; + +function formatRemaining(ms: number): string { + if (ms <= 0) return "0s"; + const s = Math.floor(ms / 1000); + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + const parts: string[] = []; + if (d > 0) parts.push(`${d}d`); + if (h > 0 || d > 0) parts.push(`${h}h`); + if (m > 0 || h > 0 || d > 0) parts.push(`${m}m`); + parts.push(`${sec}s`); + return parts.join(" "); +} + +type GiveawayCountdownProps = { + endsAt: Date; + className?: string; + showIcon?: boolean; +}; + +export function GiveawayCountdown({ + endsAt, + className = "", + showIcon = true, +}: GiveawayCountdownProps) { + const [label, setLabel] = useState(() => { + const diff = endsAt.getTime() - Date.now(); + return diff <= 0 ? "Ended" : formatRemaining(diff); + }); + + useEffect(() => { + const tick = () => { + const diff = endsAt.getTime() - Date.now(); + setLabel(diff <= 0 ? "Ended" : formatRemaining(diff)); + }; + tick(); + const id = window.setInterval(tick, 1000); + return () => window.clearInterval(id); + }, [endsAt]); + + const ended = endsAt.getTime() <= Date.now(); + + return ( +
+ {showIcon && } + {ended ? "Ended" : `${label} left`} +
+ ); +} diff --git a/app/components/post-card.tsx b/app/components/post-card.tsx index 824f5c3..11d1b5a 100644 --- a/app/components/post-card.tsx +++ b/app/components/post-card.tsx @@ -5,7 +5,6 @@ import { Calendar, ChevronDown, ChevronUp, - Clock, DollarSign, Flame, Gift, @@ -21,6 +20,7 @@ import { Button } from '@/components/ui/button'; import { CommentsSection } from '@/components/comments-section'; import { ContributionForm } from '@/components/contribution-form'; import { EntryForm } from '@/components/entry-form'; +import { GiveawayCountdown } from '@/components/giveaway-countdown'; import Link from 'next/link'; import type { Post } from '@/lib/types'; import { Progress } from '@/components/ui/progress'; @@ -125,6 +125,14 @@ export function PostCard({ post }: PostCardProps) { } }; + const giveawayDeadline = + post.type === 'giveaway' && post.endDate ? post.endDate : null; + const giveawayStillOpen = + post.type === 'giveaway' && + (post.status === 'active' || post.status === 'open') && + giveawayDeadline && + giveawayDeadline.getTime() > Date.now(); + const getProgressPercentage = () => { if ( post.type === 'request' && @@ -293,11 +301,8 @@ export function PostCard({ post }: PostCardProps) { Prize: {post.prizeAmount} {post.currency}
- {post.endDate && ( -
- - Ends {post.endDate.toLocaleDateString()} -
+ {giveawayDeadline && ( + )}
@@ -317,7 +322,7 @@ export function PostCard({ post }: PostCardProps) { )} - {post.status === 'active' && ( + {giveawayStillOpen && (