Skip to content

Commit 642a68c

Browse files
authored
Merge pull request #332 from nyx-ei/feature/session-first-flow
Feature/session first flow
2 parents 2100111 + d6140b4 commit 642a68c

10 files changed

Lines changed: 967 additions & 49 deletions

File tree

app/[locale]/sessions/[sessionId]/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ export default async function SessionPage({
257257
<SessionReviewRuntime
258258
locale={locale}
259259
sessionId={params.sessionId}
260+
groupId={data.group.id}
260261
sessionTitle={data.session.name ?? data.group.name}
261262
questionGoal={questionGoal}
262263
initialQuestionIndex={currentIndex}

app/api/sessions/[sessionId]/finish-review/route.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,31 @@ export async function POST(request: Request, { params }: RouteContext) {
9696
);
9797
}
9898

99+
const { data: plannedNextSession } = await supabase
100+
.schema('public')
101+
.from('sessions')
102+
.select('id')
103+
.eq('group_id', session.group_id)
104+
.eq('status', 'scheduled')
105+
.neq('id', sessionId)
106+
.gte('scheduled_at', new Date().toISOString())
107+
.limit(1)
108+
.maybeSingle();
109+
perf.step('next_session_loaded');
110+
111+
if (!plannedNextSession?.id) {
112+
return NextResponse.json(
113+
{
114+
ok: false,
115+
message:
116+
locale === 'fr'
117+
? 'Planifie la prochaine session avant de terminer.'
118+
: 'Schedule the next session before finishing.',
119+
},
120+
{ status: 409 },
121+
);
122+
}
123+
99124
await supabase
100125
.schema('public')
101126
.from('sessions')

app/api/sessions/[sessionId]/review-answer/route.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type ReviewPayload = {
2424
nextQuestionIndex?: number;
2525
advanceAfterSave?: boolean;
2626
correctOption?: string | null;
27+
reviewDurationSeconds?: number;
2728
};
2829

2930
export async function POST(request: Request, { params }: RouteContext) {
@@ -35,6 +36,7 @@ export async function POST(request: Request, { params }: RouteContext) {
3536
const nextQuestionIndex = Number(body?.nextQuestionIndex);
3637
const advanceAfterSave = body?.advanceAfterSave === true;
3738
const correctOption = body?.correctOption?.toUpperCase() ?? '';
39+
const reviewDurationSeconds = Number(body?.reviewDurationSeconds);
3840
const perf = createPerfTracker(
3941
`saveReviewAnswerRoute:${sessionId}:${questionIndex}`,
4042
);
@@ -156,6 +158,22 @@ export async function POST(request: Request, { params }: RouteContext) {
156158
);
157159
}
158160
perf.step('review_updates_saved');
161+
if (Number.isFinite(reviewDurationSeconds) && reviewDurationSeconds > 0) {
162+
void (
163+
supabase.schema('public') as unknown as {
164+
rpc: (
165+
fn: 'activeboard_record_review_duration',
166+
args: {
167+
target_user_id: string;
168+
duration_seconds: number;
169+
},
170+
) => Promise<unknown>;
171+
}
172+
).rpc('activeboard_record_review_duration', {
173+
target_user_id: user.id,
174+
duration_seconds: Math.min(3600, Math.max(1, Math.round(reviewDurationSeconds))),
175+
});
176+
}
159177
void logAppEvent({
160178
eventName: APP_EVENTS.answerRevealed,
161179
locale,

app/api/sessions/route.ts

Lines changed: 246 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { createPerfTracker } from '@/lib/observability/perf';
1212
import { getAppPolicySettings } from '@/lib/policy/app-policy';
1313
import { createSupabaseAdminClient } from '@/lib/supabase/admin';
1414
import { createSupabaseServerClient } from '@/lib/supabase/server';
15+
import { generateInviteCode } from '@/lib/utils';
1516

1617
type 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

2729
function 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-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-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

Comments
 (0)