@@ -3,112 +3,213 @@ import { NextRequest } from "next/server";
33import { getCurrentUser } from "@/lib/auth" ;
44import { prisma } from "@/lib/prisma" ;
55import { readJsonBody } from "@/lib/parse-json-body" ;
6+ import { z } from "zod" ;
67import { 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+
852export 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+ } ;
0 commit comments