Skip to content

Commit 1915a03

Browse files
author
Legacy
authored
Merge pull request #288 from boys-cyberhub/feat/issues-280-283-winners-wallet
feat: complete select-winners API and align wallet APIs with Stellar sync model
2 parents b50cfb9 + 5277f17 commit 1915a03

5 files changed

Lines changed: 285 additions & 82 deletions

File tree

app/app/api/posts/[id]/select-winners/route.ts

Lines changed: 164 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -3,112 +3,213 @@ import { NextRequest } from "next/server";
33
import { getCurrentUser } from "@/lib/auth";
44
import { prisma } from "@/lib/prisma";
55
import { readJsonBody } from "@/lib/parse-json-body";
6+
import { z } from "zod";
67
import { checkAndAwardBadges } from "@/lib/badges";
78

9+
// ── Per-method request schemas ────────────────────────────────────────────────
10+
11+
const randomSchema = z.object({
12+
method: z.literal("random"),
13+
count: z.number().int().positive().optional(),
14+
});
15+
16+
const manualSchema = z.object({
17+
method: z.literal("manual"),
18+
entryIds: z
19+
.array(z.string().uuid("Each entryId must be a valid UUID"))
20+
.min(1, "At least one entryId is required"),
21+
});
22+
23+
const meritSchema = z.object({
24+
method: z.literal("merit_based"),
25+
count: z.number().int().positive().optional(),
26+
});
27+
28+
const firstcomeSchema = z.object({
29+
method: z.literal("firstcome"),
30+
count: z.number().int().positive().optional(),
31+
});
32+
33+
const selectWinnersSchema = z.discriminatedUnion("method", [
34+
randomSchema,
35+
manualSchema,
36+
meritSchema,
37+
firstcomeSchema,
38+
]);
39+
40+
// ── Fisher-Yates shuffle ──────────────────────────────────────────────────────
41+
function shuffle<T>(arr: T[]): T[] {
42+
const a = [...arr];
43+
for (let i = a.length - 1; i > 0; i--) {
44+
const j = Math.floor(Math.random() * (i + 1));
45+
[a[i], a[j]] = [a[j], a[i]];
46+
}
47+
return a;
48+
}
49+
50+
// ── Route handler ─────────────────────────────────────────────────────────────
51+
852
export const POST = async (
953
request: NextRequest,
1054
{ params }: { params: Promise<{ id: string }> },
1155
) => {
1256
try {
1357
const user = await getCurrentUser(request);
14-
if (!user) return apiError('Unauthorized', 401);
58+
if (!user) return apiError("Unauthorized", 401);
1559

1660
const { id } = await params;
1761
const raw = await readJsonBody<Record<string, unknown>>(request);
1862
if (!raw.ok) return raw.response;
19-
const body = raw.data;
63+
64+
const parsed = selectWinnersSchema.safeParse(raw.data);
65+
if (!parsed.success) {
66+
return apiError(parsed.error.errors[0].message, 400);
67+
}
68+
const body = parsed.data;
2069

2170
const post = await prisma.post.findUnique({
2271
where: { id },
2372
include: {
24-
entries: true,
73+
entries: {
74+
include: { burns: true },
75+
orderBy: { createdAt: "asc" },
76+
},
2577
winners: true,
2678
},
2779
});
2880

29-
if (!post) {
30-
return apiError('Post not found', 404);
31-
}
81+
if (!post) return apiError("Post not found", 404);
82+
if (post.userId !== user.id) return apiError("Forbidden", 403);
3283

33-
if (post.userId !== user.id) {
34-
return apiError('Forbidden', 403);
84+
if (post.status === "completed") {
85+
return apiError("Winners already selected for this post", 400);
3586
}
36-
37-
if (!['open', 'active', 'in_progress'].includes(post.status)) {
38-
return apiError(`Cannot select winners for post with status ${post.status}`, 400);
87+
if (!["open", "active", "in_progress"].includes(post.status)) {
88+
return apiError(`Cannot select winners for a post with status "${post.status}"`, 400);
89+
}
90+
if (post.entries.length === 0) {
91+
return apiError("No entries to select from", 400);
3992
}
4093

41-
if (post.winners.length > 0 && post.status === 'completed') {
42-
return apiError('Winners already selected', 400);
94+
// Exclude users who are already winners (prevents duplicates across calls)
95+
const existingWinnerUserIds = new Set(post.winners.map((w) => w.userId));
96+
const eligibleEntries = post.entries.filter(
97+
(e) => !existingWinnerUserIds.has(e.userId),
98+
);
99+
100+
const maxWinners = post.maxWinners ?? 1;
101+
let selectedEntries: typeof eligibleEntries = [];
102+
103+
switch (body.method) {
104+
case "random": {
105+
const count = Math.min(body.count ?? maxWinners, eligibleEntries.length);
106+
selectedEntries = shuffle(eligibleEntries).slice(0, count);
107+
break;
108+
}
109+
110+
case "manual": {
111+
const { entryIds } = body;
112+
113+
// All supplied IDs must belong to this post
114+
const validEntryIds = new Set(post.entries.map((e) => e.id));
115+
const invalidIds = entryIds.filter((eid) => !validEntryIds.has(eid));
116+
if (invalidIds.length > 0) {
117+
return apiError(
118+
`Entry IDs not found on this post: ${invalidIds.join(", ")}`,
119+
400,
120+
);
121+
}
122+
123+
// Deduplicate supplied IDs and cap at maxWinners
124+
const uniqueIds = [...new Set(entryIds)].slice(0, maxWinners);
125+
selectedEntries = eligibleEntries.filter((e) => uniqueIds.includes(e.id));
126+
127+
if (selectedEntries.length === 0) {
128+
return apiError("None of the provided entry IDs belong to eligible entries", 400);
129+
}
130+
break;
131+
}
132+
133+
case "merit_based": {
134+
// Rank by burn count (descending), then entry age (ascending) as tiebreaker
135+
const count = Math.min(body.count ?? maxWinners, eligibleEntries.length);
136+
selectedEntries = [...eligibleEntries]
137+
.sort((a, b) => {
138+
const burnDiff = b.burns.length - a.burns.length;
139+
if (burnDiff !== 0) return burnDiff;
140+
return a.createdAt.getTime() - b.createdAt.getTime();
141+
})
142+
.slice(0, count);
143+
break;
144+
}
145+
146+
case "firstcome": {
147+
// Entries are already ordered by createdAt asc
148+
const count = Math.min(body.count ?? maxWinners, eligibleEntries.length);
149+
selectedEntries = eligibleEntries.slice(0, count);
150+
break;
151+
}
43152
}
44153

45-
const method = body.method; // 'random' or 'manual'
46-
const maxWinners = post.maxWinners || 1;
47-
48-
let selectedEntries: any[] = [];
49-
50-
if (method === 'random') {
51-
if (post.entries.length === 0) {
52-
return apiError('No entries to select from', 400);
53-
}
54-
const entriesToPickFrom = [...post.entries];
55-
// shuffle
56-
for (let i = entriesToPickFrom.length - 1; i > 0; i--) {
57-
const j = Math.floor(Math.random() * (i + 1));
58-
[entriesToPickFrom[i], entriesToPickFrom[j]] = [entriesToPickFrom[j], entriesToPickFrom[i]];
59-
}
60-
61-
selectedEntries = entriesToPickFrom.slice(0, maxWinners);
62-
} else if (method === 'manual') {
63-
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.
64-
if (!winnerIds || !Array.isArray(winnerIds) || winnerIds.length === 0) {
65-
return apiError('Manual selection requires winnerIds', 400);
66-
}
67-
68-
selectedEntries = post.entries.filter(e => winnerIds.includes(e.id) || winnerIds.includes(e.userId));
69-
if (selectedEntries.length === 0) {
70-
return apiError('No valid winners found from provided IDs', 400);
71-
}
72-
} else {
73-
return apiError('Invalid selection method. Must be random or manual', 400);
154+
if (selectedEntries.length === 0) {
155+
return apiError("No eligible entries found for the requested selection", 400);
74156
}
75157

158+
// ── Persist in a single transaction ──────────────────────────────────
76159
await prisma.$transaction(async (tx) => {
77-
const entryIds = selectedEntries.map(e => e.id);
160+
const entryIds = selectedEntries.map((e) => e.id);
161+
78162
await tx.entry.updateMany({
79163
where: { id: { in: entryIds } },
80-
data: { isWinner: true }
164+
data: { isWinner: true },
81165
});
82166

83-
const postWinnerData = selectedEntries.map(e => ({
84-
postId: post.id,
85-
userId: e.userId,
86-
assignedBy: user.id
87-
}));
88167
await tx.postWinner.createMany({
89-
data: postWinnerData,
90-
skipDuplicates: true
168+
data: selectedEntries.map((e) => ({
169+
postId: post.id,
170+
userId: e.userId,
171+
assignedBy: user.id,
172+
})),
173+
skipDuplicates: true,
91174
});
92175

93176
await tx.post.update({
94177
where: { id: post.id },
95-
data: { status: 'completed' }
178+
data: { status: "completed" },
96179
});
97-
});
98180

99-
const selectedUsers = selectedEntries.map(e => e.userId);
181+
// Notify each winner
182+
await tx.notification.createMany({
183+
data: selectedEntries.map((e) => ({
184+
userId: e.userId,
185+
type: "giveaway_win" as const,
186+
message: `Congratulations! You won the giveaway "${post.title}".`,
187+
link: `/posts/${post.id}`,
188+
})),
189+
skipDuplicates: true,
190+
});
191+
});
100192

101-
for (const winnerId of selectedUsers) {
102-
checkAndAwardBadges(winnerId).catch(console.error);
193+
// Award badges to winners async (best-effort)
194+
for (const entry of selectedEntries) {
195+
checkAndAwardBadges(entry.userId).catch(console.error);
103196
}
104197

105-
return apiSuccess({
106-
message: 'Winners selected successfully',
107-
winners: selectedUsers
108-
});
109-
198+
return apiSuccess(
199+
{
200+
method: body.method,
201+
postId: post.id,
202+
postStatus: "completed",
203+
totalSelected: selectedEntries.length,
204+
winners: selectedEntries.map((e) => ({
205+
entryId: e.id,
206+
userId: e.userId,
207+
})),
208+
},
209+
"Winners selected successfully",
210+
);
110211
} catch (error) {
111212
console.error("Select winners error:", error);
112-
return apiError('Failed to select winners', 500);
213+
return apiError("Failed to select winners", 500);
113214
}
114-
}
215+
};

app/app/api/wallet/balance/route.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,56 @@
11
import { NextRequest } from 'next/server';
22
import { apiError, apiSuccess } from '@/lib/api-response';
33
import { getCurrentUser } from '@/lib/auth';
4+
import { prisma } from '@/lib/prisma';
45

6+
/**
7+
* GET /api/wallet/balance
8+
*
9+
* Returns a normalized wallet snapshot:
10+
* - balance : local ledger balance (always available)
11+
* - assets : Stellar asset breakdown (populated when simulated=false and on-chain sync is live)
12+
* - transactions : most recent 5 transactions for quick client preview
13+
* - lastSync : ISO timestamp of the last balance reconciliation
14+
* - simulated : true when operating against the local ledger only
15+
*
16+
* Query params:
17+
* simulated=true (default) — return local balance only
18+
* simulated=false — placeholder for future on-chain Stellar fetch
19+
*/
520
export async function GET(request: NextRequest) {
621
try {
722
const currentUser = await getCurrentUser(request);
823
if (!currentUser) return apiError('Unauthorized', 401);
924

10-
return apiSuccess({ balance: currentUser.walletBalance });
25+
const { searchParams } = new URL(request.url);
26+
const simulated = searchParams.get('simulated') !== 'false';
27+
28+
const [user, recentTransactions] = await Promise.all([
29+
prisma.user.findUnique({
30+
where: { id: currentUser.id },
31+
select: { walletBalance: true, updatedAt: true },
32+
}),
33+
prisma.walletTransaction.findMany({
34+
where: { userId: currentUser.id },
35+
orderBy: { createdAt: 'desc' },
36+
take: 5,
37+
}),
38+
]);
39+
40+
if (!user) return apiError('User not found', 404);
41+
42+
return apiSuccess({
43+
balance: user.walletBalance,
44+
assets: simulated
45+
? []
46+
: [
47+
// Placeholder — replace with StellarService.getAssetBalances() when live
48+
{ code: 'XLM', issuer: null, balance: user.walletBalance },
49+
],
50+
transactions: recentTransactions,
51+
lastSync: user.updatedAt.toISOString(),
52+
simulated,
53+
});
1154
} catch {
1255
return apiError('Failed to fetch wallet balance', 500);
1356
}

app/app/api/wallet/fund/route.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ const fundSchema = z.object({
99
amount: z.number().positive('Amount must be greater than 0'),
1010
method: z.enum(['card', 'bank', 'crypto']).default('card'),
1111
note: z.string().optional(),
12+
/**
13+
* simulated=true (default) — mutate local ledger only.
14+
* simulated=false — reserved for future on-chain Stellar payment handling.
15+
*/
16+
simulated: z.boolean().default(true),
1217
});
1318

1419
export async function POST(request: NextRequest) {
@@ -23,7 +28,16 @@ export async function POST(request: NextRequest) {
2328
return apiError(parsed.error.errors[0].message, 400);
2429
}
2530

26-
const { amount, method, note } = parsed.data;
31+
const { amount, method, note, simulated } = parsed.data;
32+
33+
if (!simulated) {
34+
// On-chain path: validate Stellar txHash / memo before crediting.
35+
// Placeholder — integrate StellarService.verifyPayment() here.
36+
return apiError(
37+
'On-chain funding is not yet available. Set simulated=true for local ledger funding.',
38+
501,
39+
);
40+
}
2741

2842
const [transaction, updatedUser] = await prisma.$transaction([
2943
prisma.walletTransaction.create({
@@ -40,12 +54,17 @@ export async function POST(request: NextRequest) {
4054
prisma.user.update({
4155
where: { id: currentUser.id },
4256
data: { walletBalance: { increment: amount } },
43-
select: { walletBalance: true },
57+
select: { walletBalance: true, updatedAt: true },
4458
}),
4559
]);
4660

4761
return apiSuccess(
48-
{ balance: updatedUser.walletBalance, transaction },
62+
{
63+
balance: updatedUser.walletBalance,
64+
transaction,
65+
lastSync: updatedUser.updatedAt.toISOString(),
66+
simulated,
67+
},
4968
'Wallet funded successfully',
5069
201,
5170
);

0 commit comments

Comments
 (0)