@@ -12,6 +12,7 @@ import { createPerfTracker } from '@/lib/observability/perf';
1212import { getAppPolicySettings } from '@/lib/policy/app-policy' ;
1313import { createSupabaseAdminClient } from '@/lib/supabase/admin' ;
1414import { createSupabaseServerClient } from '@/lib/supabase/server' ;
15+ import { generateInviteCode } from '@/lib/utils' ;
1516
1617type CreateSessionPayload = {
1718 locale ?: string ;
@@ -22,6 +23,7 @@ type CreateSessionPayload = {
2223 questionGoal ?: number ;
2324 timerMode ?: 'per_question' | 'global' ;
2425 timerSeconds ?: number ;
26+ participantUserIds ?: unknown ;
2527} ;
2628
2729function groupDashboardPath ( locale : AppLocale , groupId : string ) {
@@ -69,6 +71,7 @@ export async function POST(request: Request) {
6971 . catch ( ( ) => null ) ) as CreateSessionPayload | null ;
7072 const locale = ( body ?. locale === 'fr' ? 'fr' : 'en' ) as AppLocale ;
7173 const groupId = body ?. groupId ?? '' ;
74+ const participantUserIds = parseParticipantUserIds ( body ?. participantUserIds ) ;
7275 const returnTo = body ?. returnTo ?? '' ;
7376 const sessionName = body ?. sessionName ?. trim ( ) ?? '' ;
7477 const scheduledAt = parseScheduledAt ( body ?. scheduledAt ) ;
@@ -98,7 +101,7 @@ export async function POST(request: Request) {
98101 } ;
99102
100103 if (
101- ! groupId ||
104+ ( ! groupId && participantUserIds . length === 0 ) ||
102105 ! sessionName ||
103106 ! scheduledAt ||
104107 ! Number . isFinite ( questionGoal ) ||
@@ -114,9 +117,212 @@ export async function POST(request: Request) {
114117 ) ;
115118 }
116119
120+ const supabase = createSupabaseServerClient ( ) ;
121+
122+ if ( participantUserIds . length > 0 ) {
123+ const admin = createSupabaseAdminClient ( ) ;
124+ const {
125+ data : { user } ,
126+ } = await supabase . auth . getUser ( ) ;
127+ perf . step ( 'auth_loaded' ) ;
128+
129+ if ( ! user ) {
130+ return NextResponse . json (
131+ { ok : false , redirectTo : `/${ locale } /auth/login` } ,
132+ { status : 401 } ,
133+ ) ;
134+ }
135+
136+ const memberUserIds = [
137+ ...new Set ( [ user . id , ...participantUserIds ] . filter ( Boolean ) ) ,
138+ ] ;
139+ perf . setContext ( { userId : user . id , locale } ) ;
140+
141+ if ( memberUserIds . length < policy . minimumGroupMembersToStart ) {
142+ return NextResponse . json (
143+ { ok : false , message : await getFeedback ( 'minimumMembersRequired' ) } ,
144+ { status : 400 } ,
145+ ) ;
146+ }
147+
148+ const [ userTierResult , usersResult ] = await Promise . all ( [
149+ admin
150+ . schema ( 'public' )
151+ . from ( 'users' )
152+ . select ( 'questions_answered, has_valid_payment_method, subscription_status' )
153+ . eq ( 'id' , user . id )
154+ . maybeSingle ( ) ,
155+ admin
156+ . schema ( 'public' )
157+ . from ( 'users' )
158+ . select ( 'id' )
159+ . in ( 'id' , memberUserIds ) ,
160+ ] ) ;
161+ perf . step ( 'session_first_guards_loaded' ) ;
162+
163+ const userTier = userTierResult . data
164+ ? deriveUserTier ( {
165+ questionsAnswered : userTierResult . data . questions_answered ?? 0 ,
166+ hasValidPaymentMethod :
167+ userTierResult . data . has_valid_payment_method ?? false ,
168+ subscriptionStatus : userTierResult . data . subscription_status ?? 'none' ,
169+ policy,
170+ } )
171+ : 'locked' ;
172+ if ( ! getUserTierCapabilities ( userTier ) . canCreateSession ) {
173+ return NextResponse . json (
174+ {
175+ ok : false ,
176+ message : await getFeedback ( 'upgradeRequiredToScheduleSession' ) ,
177+ } ,
178+ { status : 403 } ,
179+ ) ;
180+ }
181+
182+ if ( ( usersResult . data ?? [ ] ) . length !== memberUserIds . length ) {
183+ return NextResponse . json (
184+ { ok : false , message : await getFeedback ( 'actionFailed' ) } ,
185+ { status : 400 } ,
186+ ) ;
187+ }
188+
189+ const inviteCode = await createUniqueInviteCode ( admin ) ;
190+ const { data : createdGroup , error : groupError } = await admin
191+ . schema ( 'public' )
192+ . from ( 'groups' )
193+ . insert ( {
194+ name : sessionName ,
195+ invite_code : inviteCode ,
196+ created_by : user . id ,
197+ difficulty_level : 'medium' ,
198+ group_kind : 'session_test' ,
199+ max_members : Math . max (
200+ memberUserIds . length ,
201+ policy . minimumGroupMembersToStart ,
202+ ) ,
203+ } )
204+ . select ( 'id' )
205+ . single ( ) ;
206+
207+ if ( groupError || ! createdGroup ?. id ) {
208+ return NextResponse . json (
209+ { ok : false , message : await getFeedback ( 'actionFailed' ) } ,
210+ { status : 500 } ,
211+ ) ;
212+ }
213+ perf . step ( 'session_first_group_created' ) ;
214+
215+ const { error : membersError } = await admin
216+ . schema ( 'public' )
217+ . from ( 'group_members' )
218+ . upsert (
219+ memberUserIds . map ( ( memberUserId ) => ( {
220+ group_id : createdGroup . id ,
221+ user_id : memberUserId ,
222+ is_founder : memberUserId === user . id ,
223+ } ) ) ,
224+ { onConflict : 'group_id,user_id' } ,
225+ ) ;
226+
227+ if ( membersError ) {
228+ return NextResponse . json (
229+ { ok : false , message : await getFeedback ( 'actionFailed' ) } ,
230+ { status : 500 } ,
231+ ) ;
232+ }
233+ perf . step ( 'session_first_members_saved' ) ;
234+
235+ const { data : createdSession , error : sessionError } = await admin
236+ . schema ( 'public' )
237+ . from ( 'sessions' )
238+ . insert ( {
239+ group_id : createdGroup . id ,
240+ name : sessionName ,
241+ scheduled_at : scheduledAt . toISOString ( ) ,
242+ timer_mode : timerMode ,
243+ timer_seconds : timerSeconds ,
244+ question_goal : Math . min (
245+ Math . round ( questionGoal ) ,
246+ policy . maxQuestionGoal ,
247+ ) ,
248+ created_by : user . id ,
249+ leader_id : user . id ,
250+ status : 'scheduled' ,
251+ } )
252+ . select (
253+ 'id, group_id, name, scheduled_at, share_code, meeting_link, timer_seconds' ,
254+ )
255+ . single ( ) ;
256+
257+ if ( sessionError || ! createdSession ) {
258+ return NextResponse . json (
259+ { ok : false , message : await getFeedback ( 'actionFailed' ) } ,
260+ { status : 500 } ,
261+ ) ;
262+ }
263+ perf . step ( 'session_first_session_created' ) ;
264+
265+ void admin
266+ . schema ( 'public' )
267+ . from ( 'groups' )
268+ . update ( { last_session_id : createdSession . id } )
269+ . eq ( 'id' , createdGroup . id ) ;
270+
271+ void Promise . allSettled ( [
272+ logAppEvent ( {
273+ eventName : APP_EVENTS . sessionScheduled ,
274+ locale,
275+ userId : user . id ,
276+ groupId : createdGroup . id ,
277+ sessionId : createdSession . id ,
278+ metadata : {
279+ source : 'session_first_dashboard_modal' ,
280+ session_name : sessionName ,
281+ participant_count : memberUserIds . length ,
282+ question_goal : questionGoal ,
283+ timer_seconds : timerSeconds ,
284+ timer_mode : timerMode ,
285+ scheduled_at : scheduledAt . toISOString ( ) ,
286+ share_code : createdSession . share_code ,
287+ } ,
288+ } ) ,
289+ hasEmailEnv ( )
290+ ? sendSessionCalendarInvites ( createdSession ) . catch ( ( inviteError ) => {
291+ console . error ( 'sendSessionCalendarInvites failed' , {
292+ sessionId : createdSession . id ,
293+ groupId : createdGroup . id ,
294+ error :
295+ inviteError instanceof Error
296+ ? inviteError . message
297+ : 'Unknown calendar invite error' ,
298+ } ) ;
299+ } )
300+ : Promise . resolve ( ) ,
301+ notifySessionScheduled ( {
302+ groupId : createdGroup . id ,
303+ sessionId : createdSession . id ,
304+ sessionName,
305+ actorUserId : user . id ,
306+ } ) ,
307+ ] ) ;
308+
309+ perf . done ( {
310+ sessionId : createdSession . id ,
311+ groupId : createdGroup . id ,
312+ sessionFirst : true ,
313+ } ) ;
314+
315+ return NextResponse . json ( {
316+ ok : true ,
317+ sessionId : createdSession . id ,
318+ redirectTo : groupDashboardPath ( locale , createdGroup . id ) ,
319+ calendarInvitesDispatchUrl : `/api/sessions/${ createdSession . id } /calendar-invites` ,
320+ message : getStaticSessionScheduledFeedback ( locale ) ,
321+ } ) ;
322+ }
323+
117324 perf . setContext ( { groupId, locale } ) ;
118325
119- const supabase = createSupabaseServerClient ( ) ;
120326 const { data : fastRows , error : fastError } = await (
121327 supabase . schema ( 'public' ) as unknown as {
122328 rpc : (
@@ -382,3 +588,41 @@ function parseScheduledAt(value: string | undefined) {
382588
383589 return date ;
384590}
591+
592+ function parseParticipantUserIds ( value : unknown ) {
593+ if ( ! Array . isArray ( value ) ) {
594+ return [ ] ;
595+ }
596+
597+ return [
598+ ...new Set (
599+ value
600+ . map ( ( item ) => ( typeof item === 'string' ? item . trim ( ) : '' ) )
601+ . filter ( ( item ) =>
602+ / ^ [ 0 - 9 a - f ] { 8 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 12 } $ / i. test (
603+ item ,
604+ ) ,
605+ ) ,
606+ ) ,
607+ ] ;
608+ }
609+
610+ async function createUniqueInviteCode (
611+ admin : ReturnType < typeof createSupabaseAdminClient > ,
612+ ) {
613+ for ( let attempt = 0 ; attempt < 8 ; attempt += 1 ) {
614+ const candidate = generateInviteCode ( ) ;
615+ const { data : existing } = await admin
616+ . schema ( 'public' )
617+ . from ( 'groups' )
618+ . select ( 'id' )
619+ . eq ( 'invite_code' , candidate )
620+ . maybeSingle ( ) ;
621+
622+ if ( ! existing ) {
623+ return candidate ;
624+ }
625+ }
626+
627+ throw new Error ( 'Unable to generate a unique invite code' ) ;
628+ }
0 commit comments