11import { XP_REWARDS , awardXp } from '@/lib/xp' ;
22import { apiError , apiSuccess } from '@/lib/api-response' ;
33
4+ import { Prisma } from '@prisma/client' ;
45import { NextRequest } from 'next/server' ;
56import { getCurrentUser } from '@/lib/auth' ;
67import { prisma } from '@/lib/prisma' ;
78import { readJsonBody } from '@/lib/parse-json-body' ;
89import { 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