From 3ab417c0dac9b6858d795a2872e8e963983f8d12 Mon Sep 17 00:00:00 2001 From: boys-cyber Date: Thu, 23 Apr 2026 21:03:03 +0100 Subject: [PATCH] feat: complete select-winners API and align wallet APIs with Stellar sync model Closes #280 Closes #283 --- .../api/posts/[id]/select-winners/route.ts | 221 +++++++++++++----- app/app/api/wallet/balance/route.ts | 45 +++- app/app/api/wallet/fund/route.ts | 25 +- app/app/api/wallet/transactions/route.ts | 32 ++- app/app/api/wallet/withdraw/route.ts | 38 ++- 5 files changed, 282 insertions(+), 79 deletions(-) diff --git a/app/app/api/posts/[id]/select-winners/route.ts b/app/app/api/posts/[id]/select-winners/route.ts index 7e6b4d2..45527a5 100644 --- a/app/app/api/posts/[id]/select-winners/route.ts +++ b/app/app/api/posts/[id]/select-winners/route.ts @@ -3,6 +3,51 @@ import { NextRequest } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { readJsonBody } from "@/lib/parse-json-body"; +import { z } from "zod"; + +// ── Per-method request schemas ──────────────────────────────────────────────── + +const randomSchema = z.object({ + method: z.literal("random"), + count: z.number().int().positive().optional(), +}); + +const manualSchema = z.object({ + method: z.literal("manual"), + // Strict: only entry IDs are accepted — callers must resolve user→entry mapping + entryIds: z + .array(z.string().uuid("Each entryId must be a valid UUID")) + .min(1, "At least one entryId is required"), +}); + +const meritSchema = z.object({ + method: z.literal("merit_based"), + count: z.number().int().positive().optional(), +}); + +const firstcomeSchema = z.object({ + method: z.literal("firstcome"), + count: z.number().int().positive().optional(), +}); + +const selectWinnersSchema = z.discriminatedUnion("method", [ + randomSchema, + manualSchema, + meritSchema, + firstcomeSchema, +]); + +// ── Fisher-Yates shuffle ────────────────────────────────────────────────────── +function shuffle(arr: T[]): T[] { + const a = [...arr]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +// ── Route handler ───────────────────────────────────────────────────────────── export const POST = async ( request: NextRequest, @@ -10,100 +55,156 @@ export const POST = async ( ) => { try { const user = await getCurrentUser(request); - if (!user) return apiError('Unauthorized', 401); + if (!user) return apiError("Unauthorized", 401); const { id } = await params; const raw = await readJsonBody>(request); if (!raw.ok) return raw.response; - const body = raw.data; + + const parsed = selectWinnersSchema.safeParse(raw.data); + if (!parsed.success) { + return apiError(parsed.error.errors[0].message, 400); + } + const body = parsed.data; const post = await prisma.post.findUnique({ where: { id }, include: { - entries: true, + entries: { + include: { burns: true }, + orderBy: { createdAt: "asc" }, + }, winners: true, }, }); - if (!post) { - return apiError('Post not found', 404); - } + if (!post) return apiError("Post not found", 404); + if (post.userId !== user.id) return apiError("Forbidden", 403); - if (post.userId !== user.id) { - return apiError('Forbidden', 403); + if (post.status === "completed") { + return apiError("Winners already selected for this post", 400); } - - if (!['open', 'active', 'in_progress'].includes(post.status)) { - return apiError(`Cannot select winners for post with status ${post.status}`, 400); + if (!["open", "active", "in_progress"].includes(post.status)) { + return apiError(`Cannot select winners for a post with status "${post.status}"`, 400); + } + if (post.entries.length === 0) { + return apiError("No entries to select from", 400); } - if (post.winners.length > 0 && post.status === 'completed') { - return apiError('Winners already selected', 400); + // Exclude users who are already winners (prevents duplicates across calls) + const existingWinnerUserIds = new Set(post.winners.map((w) => w.userId)); + const eligibleEntries = post.entries.filter( + (e) => !existingWinnerUserIds.has(e.userId), + ); + + const maxWinners = post.maxWinners ?? 1; + let selectedEntries: typeof eligibleEntries = []; + + switch (body.method) { + case "random": { + const count = Math.min(body.count ?? maxWinners, eligibleEntries.length); + selectedEntries = shuffle(eligibleEntries).slice(0, count); + break; + } + + case "manual": { + const { entryIds } = body; + + // All supplied IDs must belong to this post + const validEntryIds = new Set(post.entries.map((e) => e.id)); + const invalidIds = entryIds.filter((eid) => !validEntryIds.has(eid)); + if (invalidIds.length > 0) { + return apiError( + `Entry IDs not found on this post: ${invalidIds.join(", ")}`, + 400, + ); + } + + // Deduplicate supplied IDs and cap at maxWinners + const uniqueIds = [...new Set(entryIds)].slice(0, maxWinners); + selectedEntries = eligibleEntries.filter((e) => uniqueIds.includes(e.id)); + + if (selectedEntries.length === 0) { + return apiError("None of the provided entry IDs belong to eligible entries", 400); + } + break; + } + + case "merit_based": { + // Rank by burn count (descending), then entry age (ascending) as tiebreaker + const count = Math.min(body.count ?? maxWinners, eligibleEntries.length); + selectedEntries = [...eligibleEntries] + .sort((a, b) => { + const burnDiff = b.burns.length - a.burns.length; + if (burnDiff !== 0) return burnDiff; + return a.createdAt.getTime() - b.createdAt.getTime(); + }) + .slice(0, count); + break; + } + + case "firstcome": { + // Entries are already ordered by createdAt asc + const count = Math.min(body.count ?? maxWinners, eligibleEntries.length); + selectedEntries = eligibleEntries.slice(0, count); + break; + } } - const method = body.method; // 'random' or 'manual' - const maxWinners = post.maxWinners || 1; - - let selectedEntries: any[] = []; - - if (method === 'random') { - if (post.entries.length === 0) { - return apiError('No entries to select from', 400); - } - const entriesToPickFrom = [...post.entries]; - // shuffle - for (let i = entriesToPickFrom.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [entriesToPickFrom[i], entriesToPickFrom[j]] = [entriesToPickFrom[j], entriesToPickFrom[i]]; - } - - selectedEntries = entriesToPickFrom.slice(0, maxWinners); - } else if (method === 'manual') { - const winnerIds = body.winnerIds as string[]; // this is array of entry ids or user ids? The prompt says `winnerIds?: string[]`. Usually this is entry IDs if selecting by entries, but wait... let's assume it's `entryId` because `post.entries` has `.id`. If they send userIds we can match by `userId`. Let's support `entryId`. Wait, "winnerIds" implies user ids or entry ids. Let's filter post.entries where `entryId` OR `userId` matches just in case. - if (!winnerIds || !Array.isArray(winnerIds) || winnerIds.length === 0) { - return apiError('Manual selection requires winnerIds', 400); - } - - selectedEntries = post.entries.filter(e => winnerIds.includes(e.id) || winnerIds.includes(e.userId)); - if (selectedEntries.length === 0) { - return apiError('No valid winners found from provided IDs', 400); - } - } else { - return apiError('Invalid selection method. Must be random or manual', 400); + if (selectedEntries.length === 0) { + return apiError("No eligible entries found for the requested selection", 400); } + // ── Persist in a single transaction ────────────────────────────────── await prisma.$transaction(async (tx) => { - const entryIds = selectedEntries.map(e => e.id); + const entryIds = selectedEntries.map((e) => e.id); + await tx.entry.updateMany({ where: { id: { in: entryIds } }, - data: { isWinner: true } + data: { isWinner: true }, }); - const postWinnerData = selectedEntries.map(e => ({ - postId: post.id, - userId: e.userId, - assignedBy: user.id - })); await tx.postWinner.createMany({ - data: postWinnerData, - skipDuplicates: true + data: selectedEntries.map((e) => ({ + postId: post.id, + userId: e.userId, + assignedBy: user.id, + })), + skipDuplicates: true, }); await tx.post.update({ where: { id: post.id }, - data: { status: 'completed' } + data: { status: "completed" }, }); - }); - - const selectedUsers = selectedEntries.map(e => e.userId); - return apiSuccess({ - message: 'Winners selected successfully', - winners: selectedUsers + // Notify each winner + await tx.notification.createMany({ + data: selectedEntries.map((e) => ({ + userId: e.userId, + type: "giveaway_win" as const, + message: `Congratulations! You won the giveaway "${post.title}".`, + link: `/posts/${post.id}`, + })), + skipDuplicates: true, + }); }); + return apiSuccess( + { + method: body.method, + postId: post.id, + postStatus: "completed", + totalSelected: selectedEntries.length, + winners: selectedEntries.map((e) => ({ + entryId: e.id, + userId: e.userId, + })), + }, + "Winners selected successfully", + ); } catch (error) { console.error("Select winners error:", error); - return apiError('Failed to select winners', 500); + return apiError("Failed to select winners", 500); } -} +}; diff --git a/app/app/api/wallet/balance/route.ts b/app/app/api/wallet/balance/route.ts index 27e14bd..349f643 100644 --- a/app/app/api/wallet/balance/route.ts +++ b/app/app/api/wallet/balance/route.ts @@ -1,13 +1,56 @@ import { NextRequest } from 'next/server'; import { apiError, apiSuccess } from '@/lib/api-response'; import { getCurrentUser } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +/** + * GET /api/wallet/balance + * + * Returns a normalized wallet snapshot: + * - balance : local ledger balance (always available) + * - assets : Stellar asset breakdown (populated when simulated=false and on-chain sync is live) + * - transactions : most recent 5 transactions for quick client preview + * - lastSync : ISO timestamp of the last balance reconciliation + * - simulated : true when operating against the local ledger only + * + * Query params: + * simulated=true (default) — return local balance only + * simulated=false — placeholder for future on-chain Stellar fetch + */ export async function GET(request: NextRequest) { try { const currentUser = await getCurrentUser(request); if (!currentUser) return apiError('Unauthorized', 401); - return apiSuccess({ balance: currentUser.walletBalance }); + const { searchParams } = new URL(request.url); + const simulated = searchParams.get('simulated') !== 'false'; + + const [user, recentTransactions] = await Promise.all([ + prisma.user.findUnique({ + where: { id: currentUser.id }, + select: { walletBalance: true, updatedAt: true }, + }), + prisma.walletTransaction.findMany({ + where: { userId: currentUser.id }, + orderBy: { createdAt: 'desc' }, + take: 5, + }), + ]); + + if (!user) return apiError('User not found', 404); + + return apiSuccess({ + balance: user.walletBalance, + assets: simulated + ? [] + : [ + // Placeholder — replace with StellarService.getAssetBalances() when live + { code: 'XLM', issuer: null, balance: user.walletBalance }, + ], + transactions: recentTransactions, + lastSync: user.updatedAt.toISOString(), + simulated, + }); } catch { return apiError('Failed to fetch wallet balance', 500); } diff --git a/app/app/api/wallet/fund/route.ts b/app/app/api/wallet/fund/route.ts index a05612a..2cb15eb 100644 --- a/app/app/api/wallet/fund/route.ts +++ b/app/app/api/wallet/fund/route.ts @@ -9,6 +9,11 @@ const fundSchema = z.object({ amount: z.number().positive('Amount must be greater than 0'), method: z.enum(['card', 'bank', 'crypto']).default('card'), note: z.string().optional(), + /** + * simulated=true (default) — mutate local ledger only. + * simulated=false — reserved for future on-chain Stellar payment handling. + */ + simulated: z.boolean().default(true), }); export async function POST(request: NextRequest) { @@ -23,7 +28,16 @@ export async function POST(request: NextRequest) { return apiError(parsed.error.errors[0].message, 400); } - const { amount, method, note } = parsed.data; + const { amount, method, note, simulated } = parsed.data; + + if (!simulated) { + // On-chain path: validate Stellar txHash / memo before crediting. + // Placeholder — integrate StellarService.verifyPayment() here. + return apiError( + 'On-chain funding is not yet available. Set simulated=true for local ledger funding.', + 501, + ); + } const [transaction, updatedUser] = await prisma.$transaction([ prisma.walletTransaction.create({ @@ -40,12 +54,17 @@ export async function POST(request: NextRequest) { prisma.user.update({ where: { id: currentUser.id }, data: { walletBalance: { increment: amount } }, - select: { walletBalance: true }, + select: { walletBalance: true, updatedAt: true }, }), ]); return apiSuccess( - { balance: updatedUser.walletBalance, transaction }, + { + balance: updatedUser.walletBalance, + transaction, + lastSync: updatedUser.updatedAt.toISOString(), + simulated, + }, 'Wallet funded successfully', 201, ); diff --git a/app/app/api/wallet/transactions/route.ts b/app/app/api/wallet/transactions/route.ts index 9c91ec9..2f8447d 100644 --- a/app/app/api/wallet/transactions/route.ts +++ b/app/app/api/wallet/transactions/route.ts @@ -3,27 +3,49 @@ import { apiError, apiSuccess } from '@/lib/api-response'; import { getCurrentUser } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +/** + * GET /api/wallet/transactions + * + * Returns paginated transaction history with lastSync metadata so clients can + * implement interval/focus-based refresh without polling blindly. + * + * Query params: + * page — 1-based page number (default 1) + * limit — items per page, capped at 50 (default 20) + */ export async function GET(request: NextRequest) { try { const currentUser = await getCurrentUser(request); if (!currentUser) return apiError('Unauthorized', 401); const { searchParams } = new URL(request.url); - const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10)); + const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10)); const limit = Math.min(50, Math.max(1, parseInt(searchParams.get('limit') ?? '20', 10))); - const skip = (page - 1) * limit; + const skip = (page - 1) * limit; - const [transactions, total] = await Promise.all([ + const [transactions, total, user] = await Promise.all([ prisma.walletTransaction.findMany({ - where: { userId: currentUser.id }, + where: { userId: currentUser.id }, orderBy: { createdAt: 'desc' }, skip, take: limit, }), prisma.walletTransaction.count({ where: { userId: currentUser.id } }), + prisma.user.findUnique({ + where: { id: currentUser.id }, + select: { updatedAt: true }, + }), ]); - return apiSuccess({ transactions, total, page, limit }); + return apiSuccess({ + transactions, + total, + page, + limit, + // lastSync tells the client when the server last reconciled state — use + // this timestamp to decide whether a refresh (interval or on-focus) is needed. + lastSync: user?.updatedAt.toISOString() ?? new Date().toISOString(), + }); } catch { return apiError('Failed to fetch transactions', 500); } diff --git a/app/app/api/wallet/withdraw/route.ts b/app/app/api/wallet/withdraw/route.ts index e8e5de5..3d4b748 100644 --- a/app/app/api/wallet/withdraw/route.ts +++ b/app/app/api/wallet/withdraw/route.ts @@ -9,6 +9,11 @@ const withdrawSchema = z.object({ amount: z.number().positive('Amount must be greater than 0'), method: z.enum(['bank', 'crypto', 'card']).default('bank'), note: z.string().optional(), + /** + * simulated=true (default) — mutate local ledger only. + * simulated=false — reserved for future on-chain Stellar withdrawal. + */ + simulated: z.boolean().default(true), }); export async function POST(request: NextRequest) { @@ -23,7 +28,16 @@ export async function POST(request: NextRequest) { return apiError(parsed.error.errors[0].message, 400); } - const { amount, method, note } = parsed.data; + const { amount, method, note, simulated } = parsed.data; + + if (!simulated) { + // On-chain path: build and submit a Stellar payment transaction. + // Placeholder — integrate StellarService.submitWithdrawal() here. + return apiError( + 'On-chain withdrawal is not yet available. Set simulated=true for local ledger withdrawal.', + 501, + ); + } const result = await prisma.$transaction(async (tx) => { const user = await tx.user.findUnique({ @@ -49,21 +63,25 @@ export async function POST(request: NextRequest) { const updatedUser = await tx.user.update({ where: { id: currentUser.id }, data: { walletBalance: { decrement: amount } }, - select: { walletBalance: true }, + select: { walletBalance: true, updatedAt: true }, }); - return { transaction, balance: updatedUser.walletBalance }; + return { transaction, balance: updatedUser.walletBalance, updatedAt: updatedUser.updatedAt }; }); - return apiSuccess(result, 'Withdrawal initiated successfully'); + return apiSuccess( + { + balance: result.balance, + transaction: result.transaction, + lastSync: result.updatedAt.toISOString(), + simulated, + }, + 'Withdrawal initiated successfully', + ); } catch (error) { if (error instanceof Error) { - if (error.message === 'INSUFFICIENT_BALANCE') { - return apiError('Insufficient wallet balance', 400); - } - if (error.message === 'USER_NOT_FOUND') { - return apiError('User not found', 404); - } + if (error.message === 'INSUFFICIENT_BALANCE') return apiError('Insufficient wallet balance', 400); + if (error.message === 'USER_NOT_FOUND') return apiError('User not found', 404); } return apiError('Failed to process withdrawal', 500); }