From 0ea6b1364832583deed24654b01dc074ecb26bb8 Mon Sep 17 00:00:00 2001 From: bryan Date: Mon, 22 Jun 2026 12:59:10 +0100 Subject: [PATCH 1/2] Add session-first creation flow --- app/api/sessions/route.ts | 248 +++++++++++++++++- components/session/session-review-runtime.tsx | 41 +++ components/sessions/create-session-modal.tsx | 227 +++++++++++++++- 3 files changed, 505 insertions(+), 11 deletions(-) diff --git a/app/api/sessions/route.ts b/app/api/sessions/route.ts index 32ffeab..165645d 100644 --- a/app/api/sessions/route.ts +++ b/app/api/sessions/route.ts @@ -12,6 +12,7 @@ import { createPerfTracker } from '@/lib/observability/perf'; import { getAppPolicySettings } from '@/lib/policy/app-policy'; import { createSupabaseAdminClient } from '@/lib/supabase/admin'; import { createSupabaseServerClient } from '@/lib/supabase/server'; +import { generateInviteCode } from '@/lib/utils'; type CreateSessionPayload = { locale?: string; @@ -22,6 +23,7 @@ type CreateSessionPayload = { questionGoal?: number; timerMode?: 'per_question' | 'global'; timerSeconds?: number; + participantUserIds?: unknown; }; function groupDashboardPath(locale: AppLocale, groupId: string) { @@ -69,6 +71,7 @@ export async function POST(request: Request) { .catch(() => null)) as CreateSessionPayload | null; const locale = (body?.locale === 'fr' ? 'fr' : 'en') as AppLocale; const groupId = body?.groupId ?? ''; + const participantUserIds = parseParticipantUserIds(body?.participantUserIds); const returnTo = body?.returnTo ?? ''; const sessionName = body?.sessionName?.trim() ?? ''; const scheduledAt = parseScheduledAt(body?.scheduledAt); @@ -98,7 +101,7 @@ export async function POST(request: Request) { }; if ( - !groupId || + (!groupId && participantUserIds.length === 0) || !sessionName || !scheduledAt || !Number.isFinite(questionGoal) || @@ -114,9 +117,212 @@ export async function POST(request: Request) { ); } + const supabase = createSupabaseServerClient(); + + if (participantUserIds.length > 0) { + const admin = createSupabaseAdminClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + perf.step('auth_loaded'); + + if (!user) { + return NextResponse.json( + { ok: false, redirectTo: `/${locale}/auth/login` }, + { status: 401 }, + ); + } + + const memberUserIds = [ + ...new Set([user.id, ...participantUserIds].filter(Boolean)), + ]; + perf.setContext({ userId: user.id, locale }); + + if (memberUserIds.length < policy.minimumGroupMembersToStart) { + return NextResponse.json( + { ok: false, message: await getFeedback('minimumMembersRequired') }, + { status: 400 }, + ); + } + + const [userTierResult, usersResult] = await Promise.all([ + admin + .schema('public') + .from('users') + .select('questions_answered, has_valid_payment_method, subscription_status') + .eq('id', user.id) + .maybeSingle(), + admin + .schema('public') + .from('users') + .select('id') + .in('id', memberUserIds), + ]); + perf.step('session_first_guards_loaded'); + + const userTier = userTierResult.data + ? deriveUserTier({ + questionsAnswered: userTierResult.data.questions_answered ?? 0, + hasValidPaymentMethod: + userTierResult.data.has_valid_payment_method ?? false, + subscriptionStatus: userTierResult.data.subscription_status ?? 'none', + policy, + }) + : 'locked'; + if (!getUserTierCapabilities(userTier).canCreateSession) { + return NextResponse.json( + { + ok: false, + message: await getFeedback('upgradeRequiredToScheduleSession'), + }, + { status: 403 }, + ); + } + + if ((usersResult.data ?? []).length !== memberUserIds.length) { + return NextResponse.json( + { ok: false, message: await getFeedback('actionFailed') }, + { status: 400 }, + ); + } + + const inviteCode = await createUniqueInviteCode(admin); + const { data: createdGroup, error: groupError } = await admin + .schema('public') + .from('groups') + .insert({ + name: sessionName, + invite_code: inviteCode, + created_by: user.id, + difficulty_level: 'medium', + group_kind: 'session_test', + max_members: Math.max( + memberUserIds.length, + policy.minimumGroupMembersToStart, + ), + }) + .select('id') + .single(); + + if (groupError || !createdGroup?.id) { + return NextResponse.json( + { ok: false, message: await getFeedback('actionFailed') }, + { status: 500 }, + ); + } + perf.step('session_first_group_created'); + + const { error: membersError } = await admin + .schema('public') + .from('group_members') + .upsert( + memberUserIds.map((memberUserId) => ({ + group_id: createdGroup.id, + user_id: memberUserId, + is_founder: memberUserId === user.id, + })), + { onConflict: 'group_id,user_id' }, + ); + + if (membersError) { + return NextResponse.json( + { ok: false, message: await getFeedback('actionFailed') }, + { status: 500 }, + ); + } + perf.step('session_first_members_saved'); + + const { data: createdSession, error: sessionError } = await admin + .schema('public') + .from('sessions') + .insert({ + group_id: createdGroup.id, + name: sessionName, + scheduled_at: scheduledAt.toISOString(), + timer_mode: timerMode, + timer_seconds: timerSeconds, + question_goal: Math.min( + Math.round(questionGoal), + policy.maxQuestionGoal, + ), + created_by: user.id, + leader_id: user.id, + status: 'scheduled', + }) + .select( + 'id, group_id, name, scheduled_at, share_code, meeting_link, timer_seconds', + ) + .single(); + + if (sessionError || !createdSession) { + return NextResponse.json( + { ok: false, message: await getFeedback('actionFailed') }, + { status: 500 }, + ); + } + perf.step('session_first_session_created'); + + void admin + .schema('public') + .from('groups') + .update({ last_session_id: createdSession.id }) + .eq('id', createdGroup.id); + + void Promise.allSettled([ + logAppEvent({ + eventName: APP_EVENTS.sessionScheduled, + locale, + userId: user.id, + groupId: createdGroup.id, + sessionId: createdSession.id, + metadata: { + source: 'session_first_dashboard_modal', + session_name: sessionName, + participant_count: memberUserIds.length, + question_goal: questionGoal, + timer_seconds: timerSeconds, + timer_mode: timerMode, + scheduled_at: scheduledAt.toISOString(), + share_code: createdSession.share_code, + }, + }), + hasEmailEnv() + ? sendSessionCalendarInvites(createdSession).catch((inviteError) => { + console.error('sendSessionCalendarInvites failed', { + sessionId: createdSession.id, + groupId: createdGroup.id, + error: + inviteError instanceof Error + ? inviteError.message + : 'Unknown calendar invite error', + }); + }) + : Promise.resolve(), + notifySessionScheduled({ + groupId: createdGroup.id, + sessionId: createdSession.id, + sessionName, + actorUserId: user.id, + }), + ]); + + perf.done({ + sessionId: createdSession.id, + groupId: createdGroup.id, + sessionFirst: true, + }); + + return NextResponse.json({ + ok: true, + sessionId: createdSession.id, + redirectTo: groupDashboardPath(locale, createdGroup.id), + calendarInvitesDispatchUrl: `/api/sessions/${createdSession.id}/calendar-invites`, + message: getStaticSessionScheduledFeedback(locale), + }); + } + perf.setContext({ groupId, locale }); - const supabase = createSupabaseServerClient(); const { data: fastRows, error: fastError } = await ( supabase.schema('public') as unknown as { rpc: ( @@ -382,3 +588,41 @@ function parseScheduledAt(value: string | undefined) { return date; } + +function parseParticipantUserIds(value: unknown) { + if (!Array.isArray(value)) { + return []; + } + + return [ + ...new Set( + value + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter((item) => + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + item, + ), + ), + ), + ]; +} + +async function createUniqueInviteCode( + admin: ReturnType, +) { + for (let attempt = 0; attempt < 8; attempt += 1) { + const candidate = generateInviteCode(); + const { data: existing } = await admin + .schema('public') + .from('groups') + .select('id') + .eq('invite_code', candidate) + .maybeSingle(); + + if (!existing) { + return candidate; + } + } + + throw new Error('Unable to generate a unique invite code'); +} diff --git a/components/session/session-review-runtime.tsx b/components/session/session-review-runtime.tsx index 1c1d5da..54b9bd7 100644 --- a/components/session/session-review-runtime.tsx +++ b/components/session/session-review-runtime.tsx @@ -93,6 +93,19 @@ const REVIEW_DISTRIBUTION_OPTIONS: Array = [ 'skipped', ]; +function formatSignedReviewTime(seconds: number) { + const sign = seconds < 0 ? '-' : ''; + const safeSeconds = Math.abs(seconds); + const minutes = Math.floor(safeSeconds / 60) + .toString() + .padStart(2, '0'); + const remainingSeconds = Math.floor(safeSeconds % 60) + .toString() + .padStart(2, '0'); + + return `${sign}${minutes}:${remainingSeconds}`; +} + function getDistributionCount( distribution: ReviewDistribution, option: AnswerOption | '?' | 'skipped', @@ -126,6 +139,8 @@ export function SessionReviewRuntime({ }, }); const [isLoadingQuestion, setIsLoadingQuestion] = useState(false); + const reviewTimerSeconds = Math.max(60, questionGoal * 180); + const [reviewElapsedSeconds, setReviewElapsedSeconds] = useState(0); const currentPayload = cache[currentIndex]; const currentQuestion = currentPayload?.question ?? initialQuestion; const distribution = currentPayload?.distribution ?? initialDistribution; @@ -133,6 +148,8 @@ export function SessionReviewRuntime({ const isLastQuestion = currentIndex >= questionGoal - 1; const isFirstQuestion = currentIndex <= 0; const canFinish = reviewedQuestionCount >= questionGoal; + const reviewRemainingSeconds = reviewTimerSeconds - reviewElapsedSeconds; + const isReviewOvertime = reviewRemainingSeconds < 0; const loadQuestion = useCallback( async (targetIndex: number, makeCurrent: boolean, force = false) => { @@ -203,6 +220,17 @@ export function SessionReviewRuntime({ [cache, locale, questionGoal, sessionId], ); + useEffect(() => { + const startedAt = Date.now(); + const intervalId = window.setInterval(() => { + setReviewElapsedSeconds(Math.floor((Date.now() - startedAt) / 1000)); + }, 1000); + + return () => { + window.clearInterval(intervalId); + }; + }, []); + useEffect(() => { void loadQuestion(currentIndex + 1, false); void loadQuestion(currentIndex - 1, false); @@ -332,6 +360,19 @@ export function SessionReviewRuntime({ )} +
+ {locale === 'fr' ? 'Temps de revue' : 'Review time'} + + {formatSignedReviewTime(reviewRemainingSeconds)} + +
+
+ ); +} + +function PlaceholderAvatar() { + return ( + + + ); +} + function MobileSessionList({ locale, labels, diff --git a/components/session/session-finish-review-button.tsx b/components/session/session-finish-review-button.tsx index 62b4f9a..7ec8cec 100644 --- a/components/session/session-finish-review-button.tsx +++ b/components/session/session-finish-review-button.tsx @@ -8,20 +8,158 @@ import { markDashboardPayloadStale } from '@/components/dashboard/dashboard-data export function SessionFinishReviewButton({ locale, sessionId, + groupId, + questionGoal, label, pendingLabel, }: { locale: string; sessionId: string; + groupId: string; + questionGoal: number; label: string; pendingLabel: string; }) { const router = useRouter(); const [isFinishing, setIsFinishing] = useState(false); + const [isPlannerOpen, setIsPlannerOpen] = useState(false); + const [scheduledAt, setScheduledAt] = useState(() => + getDefaultNextSessionValue(), + ); + const [sessionName, setSessionName] = useState( + locale === 'fr' ? 'Prochaine session' : 'Next session', + ); const [errorMessage, setErrorMessage] = useState(null); + async function finishReview() { + setIsFinishing(true); + setErrorMessage(null); + try { + const response = await fetch(`/api/sessions/${sessionId}/finish-review`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + cache: 'no-store', + body: JSON.stringify({ locale }), + }); + const payload = (await response.json().catch(() => null)) as { + ok?: boolean; + message?: string; + redirectTo?: string; + } | null; + + if (payload?.redirectTo && response.ok) { + window.sessionStorage.removeItem('activeboard:session-flow-active'); + markDashboardPayloadStale('sessions'); + markDashboardPayloadStale('performance'); + router.prefetch(payload.redirectTo as never); + router.replace(payload.redirectTo as never); + window.setTimeout(() => router.refresh(), 0); + return; + } + + setErrorMessage(payload?.message ?? pendingLabel); + setIsFinishing(false); + } catch { + setErrorMessage(pendingLabel); + setIsFinishing(false); + } + } + + async function planNextAndFinish() { + if (isFinishing) { + return; + } + + setIsFinishing(true); + setErrorMessage(null); + + try { + const response = await fetch('/api/sessions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + cache: 'no-store', + body: JSON.stringify({ + locale, + groupId, + sessionName, + scheduledAt, + questionGoal, + timerMode: 'per_question', + timerSeconds: 90, + }), + }); + const payload = (await response.json().catch(() => null)) as { + ok?: boolean; + message?: string; + calendarInvitesDispatchUrl?: string; + } | null; + + if (!response.ok || payload?.ok === false) { + setErrorMessage(payload?.message ?? pendingLabel); + setIsFinishing(false); + return; + } + + if (payload?.calendarInvitesDispatchUrl) { + void fetch(payload.calendarInvitesDispatchUrl, { + method: 'POST', + credentials: 'same-origin', + cache: 'no-store', + keepalive: true, + }); + } + + await finishReview(); + } catch { + setErrorMessage(pendingLabel); + setIsFinishing(false); + } + } + return (
+ {isPlannerOpen ? ( +
+

+ {locale === 'fr' + ? 'Planifie la prochaine session avant de quitter' + : 'Schedule the next session before leaving'} +

+
+ setSessionName(event.target.value)} + className="field h-10 rounded-[7px] px-3 text-sm" + placeholder={locale === 'fr' ? 'Nom de la session' : 'Session name'} + /> + setScheduledAt(event.target.value)} + className="field h-10 rounded-[7px] px-3 text-sm" + /> +
+ +
+ ) : null}
); } + +function getDefaultNextSessionValue() { + const next = new Date(); + next.setDate(next.getDate() + 2); + next.setMinutes(0, 0, 0); + return formatDateTimeLocalValue(next); +} + +function getMinDateTimeLocalValue() { + const now = new Date(); + now.setMinutes(now.getMinutes() + 15, 0, 0); + return formatDateTimeLocalValue(now); +} + +function formatDateTimeLocalValue(date: Date) { + const pad = (value: number) => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad( + date.getDate(), + )}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} diff --git a/components/session/session-flow-client.tsx b/components/session/session-flow-client.tsx index 5359b44..496e2eb 100644 --- a/components/session/session-flow-client.tsx +++ b/components/session/session-flow-client.tsx @@ -1000,6 +1000,7 @@ export function ReviewAnswerForm({ >(initialCorrectOption ?? ''); const [saveStatus, setSaveStatus] = useState('idle'); const [submissionError, setSubmissionError] = useState(null); + const reviewQuestionStartedAtRef = useRef(Date.now()); const isPending = saveStatus === 'saving'; const isReviewed = Boolean(savedCorrectOption); const canSubmit = @@ -1038,6 +1039,7 @@ export function ReviewAnswerForm({ setSavedCorrectOption(initialCorrectOption ?? ''); setSaveStatus('idle'); setSubmissionError(null); + reviewQuestionStartedAtRef.current = Date.now(); }, [initialCorrectOption, questionId]); useEffect(() => { @@ -1099,6 +1101,10 @@ export function ReviewAnswerForm({ nextQuestionIndex, advanceAfterSave: shouldAdvance, correctOption: nextCorrectOption, + reviewDurationSeconds: Math.max( + 1, + Math.round((Date.now() - reviewQuestionStartedAtRef.current) / 1000), + ), }, }, () => ({ ok: false }), diff --git a/components/session/session-review-runtime.tsx b/components/session/session-review-runtime.tsx index 54b9bd7..6ea0bd4 100644 --- a/components/session/session-review-runtime.tsx +++ b/components/session/session-review-runtime.tsx @@ -58,6 +58,7 @@ type ReviewDistribution = { type SessionReviewRuntimeProps = { locale: string; sessionId: string; + groupId: string; sessionTitle: string; questionGoal: number; initialQuestionIndex: number; @@ -117,6 +118,7 @@ function getDistributionCount( export function SessionReviewRuntime({ locale, sessionId, + groupId, sessionTitle, questionGoal, initialQuestionIndex, @@ -458,6 +460,8 @@ export function SessionReviewRuntime({ diff --git a/supabase/migrations/20260622123000_user_review_metrics.sql b/supabase/migrations/20260622123000_user_review_metrics.sql new file mode 100644 index 0000000..de41ab4 --- /dev/null +++ b/supabase/migrations/20260622123000_user_review_metrics.sql @@ -0,0 +1,55 @@ +create table if not exists public.user_review_metrics ( + user_id uuid primary key references public.users(id) on delete cascade, + reviewed_question_count integer not null default 0 check (reviewed_question_count >= 0), + total_review_seconds integer not null default 0 check (total_review_seconds >= 0), + average_review_seconds numeric generated always as ( + case + when reviewed_question_count = 0 then null + else round(total_review_seconds::numeric / reviewed_question_count, 2) + end + ) stored, + updated_at timestamptz not null default timezone('utc', now()) +); + +alter table public.user_review_metrics enable row level security; + +drop policy if exists "Users can read own review metrics" on public.user_review_metrics; +create policy "Users can read own review metrics" +on public.user_review_metrics +for select +to authenticated +using (auth.uid() = user_id); + +create or replace function public.activeboard_record_review_duration( + target_user_id uuid, + duration_seconds integer +) +returns void +language plpgsql +security definer +set search_path = public +as $$ +declare + safe_duration integer := least(3600, greatest(1, coalesce(duration_seconds, 1))); +begin + insert into public.user_review_metrics ( + user_id, + reviewed_question_count, + total_review_seconds, + updated_at + ) + values ( + target_user_id, + 1, + safe_duration, + timezone('utc', now()) + ) + on conflict (user_id) do update + set reviewed_question_count = public.user_review_metrics.reviewed_question_count + 1, + total_review_seconds = public.user_review_metrics.total_review_seconds + safe_duration, + updated_at = timezone('utc', now()); +end; +$$; + +grant execute on function public.activeboard_record_review_duration(uuid, integer) to authenticated; +