Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 164 additions & 63 deletions app/app/api/posts/[id]/select-winners/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,112 +3,213 @@ 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";
import { checkAndAwardBadges } from "@/lib/badges";

// ── 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"),
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<T>(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,
{ params }: { params: Promise<{ id: string }> },
) => {
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<Record<string, unknown>>(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);
// 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,
});
});

for (const winnerId of selectedUsers) {
checkAndAwardBadges(winnerId).catch(console.error);
// Award badges to winners async (best-effort)
for (const entry of selectedEntries) {
checkAndAwardBadges(entry.userId).catch(console.error);
}

return apiSuccess({
message: 'Winners selected successfully',
winners: selectedUsers
});

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);
}
}
};
45 changes: 44 additions & 1 deletion app/app/api/wallet/balance/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Expand Down
25 changes: 22 additions & 3 deletions app/app/api/wallet/fund/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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({
Expand All @@ -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,
);
Expand Down
Loading
Loading