Skip to content

Commit cdb45a4

Browse files
authored
Merge pull request #292 from robertocarlous/feat/Enforce-proof-required
Feat/enforce proof required
2 parents 3f32c75 + 9024a4f commit cdb45a4

2 files changed

Lines changed: 459 additions & 82 deletions

File tree

app/app/api/posts/[id]/entries/route.ts

Lines changed: 237 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,75 @@
11
import { XP_REWARDS, awardXp } from '@/lib/xp';
22
import { apiError, apiSuccess } from '@/lib/api-response';
33

4+
import { Prisma } from '@prisma/client';
45
import { NextRequest } from 'next/server';
56
import { getCurrentUser } from '@/lib/auth';
67
import { prisma } from '@/lib/prisma';
78
import { readJsonBody } from '@/lib/parse-json-body';
89
import { checkAndAwardBadges } from '@/lib/badges';
910

11+
function hasEntryProof (proofUrl: unknown, proofImage: unknown): boolean {
12+
const urlOk = typeof proofUrl === 'string' && proofUrl.trim().length > 0;
13+
const imgOk = typeof proofImage === 'string' && proofImage.trim().length > 0;
14+
return urlOk || imgOk;
15+
}
16+
17+
function txError (message: string, status: number) {
18+
return Object.assign(new Error(message), { httpStatus: status });
19+
}
20+
21+
async function notifyGiveawayEntry (
22+
tx: Prisma.TransactionClient,
23+
postOwnerId: string,
24+
entrantName: string,
25+
postId: string,
26+
) {
27+
try {
28+
await tx.notification.create({
29+
data: {
30+
userId: postOwnerId,
31+
type: 'giveaway_entry',
32+
message: `${entrantName} entered your giveaway`,
33+
link: `/post/${postId}`,
34+
},
35+
});
36+
} catch (err: unknown) {
37+
const code = (err as { code?: string })?.code;
38+
const modelName = String((err as { meta?: { modelName?: string } })?.meta?.modelName || '').toLowerCase();
39+
if (code === 'P2021' && modelName === 'notification') {
40+
return;
41+
}
42+
throw err;
43+
}
44+
}
45+
46+
async function notifyGiveawayWins (
47+
tx: Prisma.TransactionClient,
48+
userIds: string[],
49+
postTitle: string,
50+
postId: string,
51+
) {
52+
if (userIds.length === 0) return;
53+
try {
54+
await tx.notification.createMany({
55+
data: userIds.map((userId) => ({
56+
userId,
57+
type: 'giveaway_win' as const,
58+
message: `Congratulations! You won the giveaway "${postTitle}".`,
59+
link: `/post/${postId}`,
60+
})),
61+
skipDuplicates: true,
62+
});
63+
} catch (err: unknown) {
64+
const code = (err as { code?: string })?.code;
65+
const modelName = String((err as { meta?: { modelName?: string } })?.meta?.modelName || '').toLowerCase();
66+
if (code === 'P2021' && modelName === 'notification') {
67+
return;
68+
}
69+
throw err;
70+
}
71+
}
72+
1073
/**
1174
* POST /api/posts/[id]/entries
1275
* Submit an entry to a giveaway post
@@ -23,9 +86,12 @@ export async function POST (
2386
const raw = await readJsonBody<Record<string, unknown>>(request);
2487
if (!raw.ok) return raw.response;
2588
const body = raw.data;
26-
const { content, proofUrl } = body as { content?: unknown; proofUrl?: unknown };
89+
const { content, proofUrl, proofImage } = body as {
90+
content?: unknown;
91+
proofUrl?: unknown;
92+
proofImage?: unknown;
93+
};
2794

28-
// Validate content
2995
if (!content || typeof content !== 'string') {
3096
return apiError('Content is required', 400);
3197
}
@@ -34,105 +100,196 @@ export async function POST (
34100
return apiError('Content must be between 10 and 5000 characters', 400);
35101
}
36102

37-
// Check if post exists and is open
38-
const post = await prisma.post.findUnique({
39-
where: { id: postId },
40-
select: { id: true, status: true, userId: true, type: true },
41-
});
103+
let createdEntry: Awaited<ReturnType<typeof prisma.entry.create>>;
104+
let newlyWinningUserIds: string[] = [];
42105

43-
if (!post) {
44-
return apiError('Post not found', 404);
45-
}
106+
try {
107+
const txResult = await prisma.$transaction(async (tx) => {
108+
let newlyWinning: string[] = [];
109+
const post = await tx.post.findUnique({
110+
where: { id: postId },
111+
include: {
112+
requirements: { select: { proofRequired: true } },
113+
winners: { select: { userId: true } },
114+
},
115+
});
46116

47-
if (post.type !== 'giveaway') {
48-
return apiError('Entries can only be submitted to giveaway posts', 400);
49-
}
117+
if (!post) {
118+
throw txError('Post not found', 404);
119+
}
50120

51-
if (post.status !== 'open') {
52-
return apiError('Post is not accepting entries', 400);
53-
}
121+
if (post.type !== 'giveaway') {
122+
throw txError('Entries can only be submitted to giveaway posts', 400);
123+
}
54124

55-
// Prevent creators from entering their own posts
56-
if (post.userId === user.id) {
57-
return apiError('You cannot enter your own giveaway', 403);
58-
}
125+
if (post.status !== 'open') {
126+
throw txError('Post is not accepting entries', 400);
127+
}
59128

60-
// Check for existing entry (unique constraint will catch this too, but we provide better error message)
61-
const existingEntry = await prisma.entry.findUnique({
62-
where: {
63-
postId_userId: {
64-
postId,
65-
userId: user.id,
66-
},
67-
},
68-
});
129+
if (post.userId === user.id) {
130+
throw txError('You cannot enter your own giveaway', 403);
131+
}
69132

70-
if (existingEntry) {
71-
return apiError('You have already entered this giveaway', 400);
72-
}
133+
const proofRequired =
134+
Boolean(post.proofRequired) ||
135+
Boolean(post.requirements?.proofRequired);
73136

137+
if (proofRequired && !hasEntryProof(proofUrl, proofImage)) {
138+
throw txError('Proof is required for this giveaway (provide proofUrl or proofImage)', 400);
139+
}
74140

75-
// Create entry
76-
const entry = await prisma.$transaction(async (tx) => {
77-
const createdEntry = await tx.entry.create({
78-
data: {
79-
postId,
80-
userId: user.id,
81-
content,
82-
proofUrl: proofUrl || null,
83-
},
84-
include: {
85-
user: {
86-
select: {
87-
id: true,
88-
name: true,
89-
walletAddress: true,
90-
avatarUrl: true,
141+
const existingEntry = await tx.entry.findUnique({
142+
where: {
143+
postId_userId: {
144+
postId,
145+
userId: user.id,
91146
},
92147
},
93-
},
94-
});
148+
});
149+
150+
if (existingEntry) {
151+
throw txError('You have already entered this giveaway', 400);
152+
}
95153

96-
await awardXp(
97-
user.id,
98-
XP_REWARDS.enterGiveaway,
99-
'giveaway_entered',
100-
{
101-
metadata: {
154+
const maxWinners = post.maxWinners ?? 1;
155+
156+
if (post.selectionMethod === 'firstcome') {
157+
const entryCount = await tx.entry.count({ where: { postId } });
158+
if (entryCount >= maxWinners) {
159+
throw txError('This giveaway has filled all winner slots', 400);
160+
}
161+
}
162+
163+
const proofUrlStr =
164+
typeof proofUrl === 'string' && proofUrl.trim().length > 0
165+
? proofUrl.trim()
166+
: null;
167+
const proofImageStr =
168+
typeof proofImage === 'string' && proofImage.trim().length > 0
169+
? proofImage.trim()
170+
: null;
171+
172+
const newEntry = await tx.entry.create({
173+
data: {
102174
postId,
103-
entryId: createdEntry.id,
175+
userId: user.id,
176+
content,
177+
proofUrl: proofUrlStr,
178+
proofImage: proofImageStr,
104179
},
105-
},
106-
tx,
107-
);
108-
109-
// Notify post owner, but ignore if notifications support is unavailable
110-
if (post.userId && tx.notification?.create) {
111-
try {
112-
await tx.notification.create({
113-
data: {
114-
userId: post.userId,
115-
type: 'giveaway_entry',
116-
message: `${user.name} entered your giveaway`,
117-
link: `/post/${postId}`,
180+
include: {
181+
user: {
182+
select: {
183+
id: true,
184+
name: true,
185+
walletAddress: true,
186+
avatarUrl: true,
187+
},
188+
},
189+
},
190+
});
191+
192+
await awardXp(
193+
user.id,
194+
XP_REWARDS.enterGiveaway,
195+
'giveaway_entered',
196+
{
197+
metadata: {
198+
postId,
199+
entryId: newEntry.id,
118200
},
201+
},
202+
tx,
203+
);
204+
205+
await notifyGiveawayEntry(tx, post.userId, user.name, postId);
206+
207+
if (post.selectionMethod === 'firstcome') {
208+
const priorWinnerUserIds = new Set(post.winners.map((w) => w.userId));
209+
210+
const orderedEntries = await tx.entry.findMany({
211+
where: { postId },
212+
orderBy: { createdAt: 'asc' },
213+
});
214+
215+
const top = orderedEntries.slice(0, maxWinners);
216+
const topIds = top.map((e) => e.id);
217+
218+
if (topIds.length > 0) {
219+
await tx.entry.updateMany({
220+
where: { postId, id: { in: topIds } },
221+
data: { isWinner: true },
222+
});
223+
}
224+
225+
const nonTopIds = orderedEntries
226+
.filter((e) => !topIds.includes(e.id))
227+
.map((e) => e.id);
228+
229+
if (nonTopIds.length > 0) {
230+
await tx.entry.updateMany({
231+
where: { postId, id: { in: nonTopIds } },
232+
data: { isWinner: false },
233+
});
234+
}
235+
236+
await tx.postWinner.createMany({
237+
data: top.map((e) => ({
238+
postId,
239+
userId: e.userId,
240+
assignedBy: post.userId,
241+
})),
242+
skipDuplicates: true,
119243
});
120-
} catch (err: any) {
121-
if (err?.code === 'P2021' && String(err?.meta?.modelName).toLowerCase() === 'notification') {
122-
// Table does not exist, skip notification
123-
// Optionally log: console.warn('Notification table missing, skipping notification.');
124-
} else {
125-
throw err;
244+
245+
newlyWinning = top
246+
.map((e) => e.userId)
247+
.filter((uid) => !priorWinnerUserIds.has(uid));
248+
249+
await notifyGiveawayWins(tx, newlyWinning, post.title, postId);
250+
251+
if (orderedEntries.length >= maxWinners) {
252+
await tx.post.update({
253+
where: { id: postId },
254+
data: { status: 'completed' },
255+
});
126256
}
127257
}
128-
}
129258

130-
return createdEntry;
131-
});
259+
return { newEntry, newlyWinning };
260+
}, {
261+
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
262+
maxWait: 5000,
263+
timeout: 10000,
264+
});
265+
createdEntry = txResult.newEntry;
266+
newlyWinningUserIds = txResult.newlyWinning;
267+
} catch (error: unknown) {
268+
if (
269+
error instanceof Prisma.PrismaClientKnownRequestError &&
270+
error.code === 'P2002'
271+
) {
272+
return apiError('You have already entered this giveaway', 400);
273+
}
274+
if (
275+
error instanceof Prisma.PrismaClientKnownRequestError &&
276+
error.code === 'P2034'
277+
) {
278+
return apiError('Could not submit entry, please try again', 409);
279+
}
280+
const httpStatus = (error as { httpStatus?: number }).httpStatus;
281+
if (typeof httpStatus === 'number' && error instanceof Error) {
282+
return apiError(error.message, httpStatus);
283+
}
284+
throw error;
285+
}
132286

133287
checkAndAwardBadges(user.id).catch(console.error);
288+
for (const uid of newlyWinningUserIds) {
289+
checkAndAwardBadges(uid).catch(console.error);
290+
}
134291

135-
return apiSuccess(entry, 'Entry created successfully', 201);
292+
return apiSuccess(createdEntry, 'Entry created successfully', 201);
136293
} catch (error) {
137294
console.error('Error creating entry:', error);
138295
return apiError('Failed to create entry', 500);

0 commit comments

Comments
 (0)