Skip to content

Commit 0ea6b13

Browse files
author
bryan
committed
Add session-first creation flow
1 parent 2100111 commit 0ea6b13

3 files changed

Lines changed: 505 additions & 11 deletions

File tree

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+
}

components/session/session-review-runtime.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,19 @@ const REVIEW_DISTRIBUTION_OPTIONS: Array<AnswerOption | '?' | 'skipped'> = [
9393
'skipped',
9494
];
9595

96+
function formatSignedReviewTime(seconds: number) {
97+
const sign = seconds < 0 ? '-' : '';
98+
const safeSeconds = Math.abs(seconds);
99+
const minutes = Math.floor(safeSeconds / 60)
100+
.toString()
101+
.padStart(2, '0');
102+
const remainingSeconds = Math.floor(safeSeconds % 60)
103+
.toString()
104+
.padStart(2, '0');
105+
106+
return `${sign}${minutes}:${remainingSeconds}`;
107+
}
108+
96109
function getDistributionCount(
97110
distribution: ReviewDistribution,
98111
option: AnswerOption | '?' | 'skipped',
@@ -126,13 +139,17 @@ export function SessionReviewRuntime({
126139
},
127140
});
128141
const [isLoadingQuestion, setIsLoadingQuestion] = useState(false);
142+
const reviewTimerSeconds = Math.max(60, questionGoal * 180);
143+
const [reviewElapsedSeconds, setReviewElapsedSeconds] = useState(0);
129144
const currentPayload = cache[currentIndex];
130145
const currentQuestion = currentPayload?.question ?? initialQuestion;
131146
const distribution = currentPayload?.distribution ?? initialDistribution;
132147
const myReviewAnswer = currentPayload?.ownAnswer ?? initialOwnAnswer;
133148
const isLastQuestion = currentIndex >= questionGoal - 1;
134149
const isFirstQuestion = currentIndex <= 0;
135150
const canFinish = reviewedQuestionCount >= questionGoal;
151+
const reviewRemainingSeconds = reviewTimerSeconds - reviewElapsedSeconds;
152+
const isReviewOvertime = reviewRemainingSeconds < 0;
136153

137154
const loadQuestion = useCallback(
138155
async (targetIndex: number, makeCurrent: boolean, force = false) => {
@@ -203,6 +220,17 @@ export function SessionReviewRuntime({
203220
[cache, locale, questionGoal, sessionId],
204221
);
205222

223+
useEffect(() => {
224+
const startedAt = Date.now();
225+
const intervalId = window.setInterval(() => {
226+
setReviewElapsedSeconds(Math.floor((Date.now() - startedAt) / 1000));
227+
}, 1000);
228+
229+
return () => {
230+
window.clearInterval(intervalId);
231+
};
232+
}, []);
233+
206234
useEffect(() => {
207235
void loadQuestion(currentIndex + 1, false);
208236
void loadQuestion(currentIndex - 1, false);
@@ -332,6 +360,19 @@ export function SessionReviewRuntime({
332360
)}
333361
</div>
334362

363+
<div
364+
className={`flex items-center justify-between rounded-[10px] border px-3 py-2 text-xs font-bold sm:text-sm ${
365+
isReviewOvertime
366+
? 'border-amber-300/30 bg-amber-300/10 text-amber-200'
367+
: 'border-brand/20 bg-brand/10 text-[#9FF0CE]'
368+
}`}
369+
>
370+
<span>{locale === 'fr' ? 'Temps de revue' : 'Review time'}</span>
371+
<span className="font-mono text-sm sm:text-base">
372+
{formatSignedReviewTime(reviewRemainingSeconds)}
373+
</span>
374+
</div>
375+
335376
<section className="surface-mockup p-3 sm:p-5">
336377
<div className="flex items-center gap-2">
337378
<BarChart3 className="h-4 w-4 text-brand" aria-hidden="true" />

0 commit comments

Comments
 (0)