Skip to content

Commit 26e64ad

Browse files
Add profile update API and harden client JSON handling
1 parent f9fd8af commit 26e64ad

13 files changed

Lines changed: 208 additions & 69 deletions

app/api/profile/route.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import assert from 'node:assert/strict';
2+
import test from 'node:test';
3+
import { GET, PUT, profileRouteDeps } from './route';
4+
import { defaultStudentProfile, type StudentProfile } from '@/src/features/dashboard/student-profile-model';
5+
6+
const sampleProfile: StudentProfile = {
7+
...defaultStudentProfile,
8+
name: 'Harpreet Singh',
9+
classDesignation: 'Undergraduate',
10+
userRole: 'College Student (Undergraduate)',
11+
ageRange: '19-22',
12+
primaryLearningGoals: ['Web Development'],
13+
learningPace: 'Focused',
14+
onboardingCompleted: true,
15+
onboardingCompletedAt: '2026-04-07T12:00:00.000Z',
16+
};
17+
18+
test('PUT /api/profile rejects invalid payloads', async (t) => {
19+
const originalDeps = { ...profileRouteDeps };
20+
t.after(() => Object.assign(profileRouteDeps, originalDeps));
21+
22+
profileRouteDeps.normalizeStudentProfileInput = () => null;
23+
24+
const response = await PUT(
25+
new Request('http://localhost/api/profile', {
26+
method: 'PUT',
27+
headers: { 'Content-Type': 'application/json' },
28+
body: JSON.stringify({ nope: true }),
29+
}),
30+
);
31+
const data = (await response.json()) as { error?: string };
32+
33+
assert.equal(response.status, 400);
34+
assert.match(data.error || '', /invalid student profile payload/i);
35+
});
36+
37+
test('PUT /api/profile returns 401 when there is no authenticated user', async (t) => {
38+
const originalDeps = { ...profileRouteDeps };
39+
t.after(() => Object.assign(profileRouteDeps, originalDeps));
40+
41+
profileRouteDeps.normalizeStudentProfileInput = () => sampleProfile;
42+
profileRouteDeps.updateAuthenticatedProfile = async () => null;
43+
44+
const response = await PUT(
45+
new Request('http://localhost/api/profile', {
46+
method: 'PUT',
47+
headers: { 'Content-Type': 'application/json' },
48+
body: JSON.stringify(sampleProfile),
49+
}),
50+
);
51+
const data = (await response.json()) as { error?: string };
52+
53+
assert.equal(response.status, 401);
54+
assert.match(data.error || '', /unauthorized/i);
55+
});
56+
57+
test('PUT /api/profile returns the saved profile and default profile', async (t) => {
58+
const originalDeps = { ...profileRouteDeps };
59+
t.after(() => Object.assign(profileRouteDeps, originalDeps));
60+
61+
profileRouteDeps.normalizeStudentProfileInput = () => sampleProfile;
62+
profileRouteDeps.updateAuthenticatedProfile = async () => ({
63+
user: { id: 'user-123', email: 'harpreet@example.com' } as never,
64+
profile: sampleProfile,
65+
defaultProfile: sampleProfile,
66+
supportsOnboardingSchema: true,
67+
supportsEnhancedOnboardingSchema: true,
68+
});
69+
70+
const response = await PUT(
71+
new Request('http://localhost/api/profile', {
72+
method: 'PUT',
73+
headers: { 'Content-Type': 'application/json' },
74+
body: JSON.stringify(sampleProfile),
75+
}),
76+
);
77+
const data = (await response.json()) as {
78+
profile?: StudentProfile;
79+
defaultProfile?: StudentProfile;
80+
};
81+
82+
assert.equal(response.status, 200);
83+
assert.equal(data.profile?.name, 'Harpreet Singh');
84+
assert.deepEqual(data.defaultProfile, sampleProfile);
85+
});
86+
87+
test('GET /api/profile returns unauthorized when there is no authenticated user', async (t) => {
88+
const originalDeps = { ...profileRouteDeps };
89+
t.after(() => Object.assign(profileRouteDeps, originalDeps));
90+
91+
profileRouteDeps.createClient = async () =>
92+
({
93+
auth: {
94+
getUser: async () => ({
95+
data: { user: null },
96+
error: null,
97+
}),
98+
},
99+
}) as never;
100+
101+
const response = await GET();
102+
const data = (await response.json()) as { error?: string };
103+
104+
assert.equal(response.status, 401);
105+
assert.match(data.error || '', /unauthorized/i);
106+
});

app/api/profile/route.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
1+
import { normalizeStudentProfileInput } from '@/src/features/dashboard/student-profile-model';
2+
import { updateAuthenticatedProfile } from '@/src/lib/supabase/profiles';
13
import { createClient } from '@/src/lib/supabase/server';
24
import { NextResponse } from 'next/server';
35

6+
export const profileRouteDeps = {
7+
createClient,
8+
normalizeStudentProfileInput,
9+
updateAuthenticatedProfile,
10+
};
11+
412
/**
513
* Phase 5: Profile API Extension
614
* Returns the student profile along with their current skill mastery data.
715
*/
816
export async function GET() {
917
try {
10-
const supabase = await createClient();
18+
const supabase = await profileRouteDeps.createClient();
1119

1220
// 1. Identity Check
1321
const { data: { user }, error: authError } = await supabase.auth.getUser();
@@ -39,4 +47,31 @@ export async function GET() {
3947
console.error('[PROFILE_GET_ERROR]', error);
4048
return NextResponse.json({ error: error.message || 'Internal Server Error' }, { status: 500 });
4149
}
42-
}
50+
}
51+
52+
export async function PUT(request: Request) {
53+
try {
54+
const payload = profileRouteDeps.normalizeStudentProfileInput(await request.json().catch(() => null));
55+
56+
if (!payload) {
57+
return NextResponse.json({ error: 'Invalid student profile payload.' }, { status: 400 });
58+
}
59+
60+
const result = await profileRouteDeps.updateAuthenticatedProfile(payload);
61+
62+
if (!result) {
63+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
64+
}
65+
66+
return NextResponse.json({
67+
profile: result.profile,
68+
defaultProfile: result.defaultProfile,
69+
});
70+
} catch (error: any) {
71+
console.error('[PROFILE_PUT_ERROR]', error);
72+
return NextResponse.json(
73+
{ error: error?.message || 'Yantra could not save the student profile right now.' },
74+
{ status: 500 },
75+
);
76+
}
77+
}

src/features/dashboard/RosterPageClient.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useRef, useState } from 'react';
66
import StudentProfileCard, { type StudentProfileCardHandle } from './StudentProfileCard';
77
import { type StudentProfile } from './student-profile-model';
88
import { startRouteTransition } from '@/src/features/motion/ExperienceProvider';
9+
import { readJsonResponse } from '@/src/lib/read-json-response';
910

1011
type Props = {
1112
initialProfileData: StudentProfile;
@@ -24,14 +25,14 @@ export default function RosterPageClient({ initialProfileData, defaultProfileDat
2425
body: JSON.stringify(nextProfile),
2526
});
2627

27-
const payload = (await response.json()) as { error?: string; profile?: StudentProfile };
28+
const payload = await readJsonResponse<{ error?: string; profile?: StudentProfile }>(response);
2829

29-
if (!response.ok || !payload.profile) {
30+
if (!response.ok || !payload?.profile) {
3031
if (response.status === 401) {
3132
startRouteTransition({ href: '/login', label: 'Returning to Login' });
3233
window.location.href = '/login?message=Your%20session%20expired.&kind=error';
3334
}
34-
const msg = payload.error || 'Yantra could not save the student profile right now.';
35+
const msg = payload?.error || 'Yantra could not save the student profile right now.';
3536
setStatusMessage(msg);
3637
throw new Error(msg);
3738
}

src/features/dashboard/StudentProfileOverview.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Link from 'next/link';
44
import { useEffect, useRef, useState } from 'react';
55
import { Bell, ChevronRight, Grid2x2 } from 'lucide-react';
66
import { startRouteTransition } from '@/src/features/motion/ExperienceProvider';
7+
import { readJsonResponse } from '@/src/lib/read-json-response';
78
import StudentProfileCard, { type StudentProfileCardHandle } from './StudentProfileCard';
89
import { defaultStudentProfile, type StudentProfile } from './student-profile-model';
910

@@ -77,18 +78,18 @@ export default function StudentProfileOverview({ initialProfileData, defaultProf
7778
body: JSON.stringify(nextProfile),
7879
});
7980

80-
const payload = (await response.json()) as {
81+
const payload = await readJsonResponse<{
8182
error?: string;
8283
profile?: StudentProfile;
8384
defaultProfile?: StudentProfile;
84-
};
85+
}>(response);
8586

86-
if (!response.ok || !payload.profile) {
87+
if (!response.ok || !payload?.profile) {
8788
if (response.status === 401) {
8889
startRouteTransition({ href: '/login', label: 'Returning to Login' });
8990
window.location.href = '/login?message=Your%20session%20expired.%20Please%20log%20in%20again.&kind=error';
9091
}
91-
throw new Error(payload.error || 'Yantra could not save the student profile right now.');
92+
throw new Error(payload?.error || 'Yantra could not save the student profile right now.');
9293
}
9394

9495
setProfile(payload.profile);

src/features/dashboard/StudentProfilePage.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
UserCircle2,
1313
} from 'lucide-react';
1414
import { startRouteTransition } from '@/src/features/motion/ExperienceProvider';
15+
import { readJsonResponse } from '@/src/lib/read-json-response';
1516
import YantraMobileMenu from '@/src/features/navigation/YantraMobileMenu';
1617
import StudentProfileCard, { type StudentProfileCardHandle } from './StudentProfileCard';
1718
import YantraAmbientBackground from './YantraAmbientBackground';
@@ -350,19 +351,19 @@ export default function StudentProfilePage({
350351
body: JSON.stringify(nextProfile),
351352
});
352353

353-
const payload = (await response.json()) as {
354+
const payload = await readJsonResponse<{
354355
error?: string;
355356
profile?: StudentProfile;
356357
defaultProfile?: StudentProfile;
357-
};
358+
}>(response);
358359

359-
if (!response.ok || !payload.profile) {
360+
if (!response.ok || !payload?.profile) {
360361
if (response.status === 401) {
361362
startRouteTransition({ href: '/login', label: 'Returning to Login' });
362363
window.location.href = '/login?message=Your%20session%20expired.%20Please%20log%20in%20again.&kind=error';
363364
}
364365

365-
throw new Error(payload.error || 'Yantra could not save the student profile right now.');
366+
throw new Error(payload?.error || 'Yantra could not save the student profile right now.');
366367
}
367368

368369
setProfile(payload.profile);

src/features/dashboard/student-dashboard-generation.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ test('buildDeterministicDashboardSnapshot keeps new-user history honest', () =>
2424
assert.equal(snapshot.path.nextSessionDateDay, '--');
2525
assert.equal(snapshot.path.nextSessionDateMonth, 'Suggested');
2626
assert.equal(snapshot.path.nextSessionTimeLabel, 'Pick a room to begin');
27-
assert.equal(snapshot.path.nextSessionInstructorName, 'Yantra Guide');
27+
assert.equal(snapshot.path.nextSessionInstructorName, 'AI Teacher');
2828
assert.equal(snapshot.path.weeklyCompletedSessions, 0);
2929
assert.ok(snapshot.weeklyActivity.every((bar) => bar.fillHeight === 0));
3030
assert.match(snapshot.confidenceSummary, /onboarding answers first/i);

src/features/marketing/marketing-content.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const marketingNavLinks = [
2323
{ label: 'Use Cases', href: '#campus-life' },
2424
{ label: 'Access', href: '#contact' },
2525
{ label: 'Docs', href: '/docs' },
26+
{ label: 'Contributors', href: '/contributors' },
2627
] as const;
2728

2829
export const marketingTickerItems = [

src/features/navigation/GlobalSidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export default function GlobalSidebar({ className, disableDesktop = false }: { c
88

99
const links: YantraMobileMenuLink[] = [
1010
{ label: 'Platform', href: '/' },
11+
{ label: 'Contributors', href: '/contributors' },
1112
{ label: 'Dashboard', href: '/dashboard' },
1213
{ label: 'Curriculum', href: '/dashboard/student-profile/curriculum' },
1314
{ label: 'Docs', href: '/docs/first-dashboard-session' },

src/features/onboarding/RoleOnboardingExperience.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
} from '@/src/features/dashboard/student-profile-model';
4545
import { usePageTransition } from '@/src/features/motion/ExperienceProvider';
4646
import GlobalSidebar from '@/src/features/navigation/GlobalSidebar';
47+
import { readJsonResponse } from '@/src/lib/read-json-response';
4748

4849
type OnboardingStatus =
4950
| {
@@ -299,14 +300,14 @@ export default function RoleOnboardingExperience({
299300
} satisfies StudentProfile),
300301
});
301302

302-
const payload = (await response.json()) as { error?: string; profile?: StudentProfile };
303-
if (!response.ok || !payload.profile) {
303+
const payload = await readJsonResponse<{ error?: string; profile?: StudentProfile }>(response);
304+
if (!response.ok || !payload?.profile) {
304305
if (response.status === 401) {
305306
window.location.href = '/login?message=Your%20session%20expired.%20Please%20log%20in%20again.&kind=error';
306307
return;
307308
}
308309

309-
throw new Error(payload.error || 'Yantra could not save your onboarding answers right now.');
310+
throw new Error(payload?.error || 'Yantra could not save your onboarding answers right now.');
310311
}
311312

312313
setStatus({ kind: 'info', message: 'Profile saved. Building your dashboard roadmap...' });

src/features/rooms/RoomVoiceAssistant.tsx

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const MIN_SPEECH_MS = 450;
4747
const MIN_CAPTURED_LEVEL = 0.08;
4848
const HANDS_FREE_REARM_DELAY_MS = 900;
4949
const PLAYBACK_BOOST_GAIN = 2.4;
50+
const INPUT_MONITOR_INTERVAL_MS = 80;
5051

5152
type PlaybackOptions = {
5253
blockedMessage: string;
@@ -278,7 +279,6 @@ const RoomVoiceAssistant = forwardRef<RoomVoiceAssistantHandle, RoomVoiceAssista
278279
const peakMicLevelRef = useRef(0);
279280
const silentSinceRef = useRef<number | null>(null);
280281
const noSpeechTimerRef = useRef<number | null>(null);
281-
const [, setMicLevel] = useState(0);
282282
const shouldResumeAfterSpeechRef = useRef(false);
283283
const handsFreeEnabledRef = useRef(false);
284284
const lastAnalyserTickRef = useRef<number | null>(null);
@@ -430,7 +430,6 @@ const RoomVoiceAssistant = forwardRef<RoomVoiceAssistantHandle, RoomVoiceAssista
430430
peakMicLevelRef.current = 0;
431431
silentSinceRef.current = null;
432432
lastAnalyserTickRef.current = null;
433-
setMicLevel(0);
434433
inputSourceRef.current?.disconnect();
435434
inputAnalyserRef.current?.disconnect();
436435
inputSourceRef.current = null;
@@ -481,7 +480,16 @@ const RoomVoiceAssistant = forwardRef<RoomVoiceAssistantHandle, RoomVoiceAssista
481480
const activeRecorder = mediaRecorderRef.current;
482481

483482
if (!activeAnalyser || !activeRecorder || activeRecorder.state !== 'recording') {
484-
setMicLevel(0);
483+
return;
484+
}
485+
486+
const now = performance.now();
487+
488+
if (
489+
lastAnalyserTickRef.current !== null &&
490+
now - lastAnalyserTickRef.current < INPUT_MONITOR_INTERVAL_MS
491+
) {
492+
inputMonitorFrameRef.current = window.requestAnimationFrame(tick);
485493
return;
486494
}
487495

@@ -493,11 +501,9 @@ const RoomVoiceAssistant = forwardRef<RoomVoiceAssistantHandle, RoomVoiceAssista
493501

494502
const rms = Math.sqrt(sumSquares / samples.length);
495503
const normalizedLevel = Math.min(1, rms * 10);
496-
setMicLevel(normalizedLevel);
497504
peakMicLevelRef.current = Math.max(peakMicLevelRef.current, normalizedLevel);
498-
499-
const now = performance.now();
500-
const deltaMs = lastAnalyserTickRef.current === null ? 0 : now - lastAnalyserTickRef.current;
505+
const deltaMs =
506+
lastAnalyserTickRef.current === null ? INPUT_MONITOR_INTERVAL_MS : now - lastAnalyserTickRef.current;
501507
lastAnalyserTickRef.current = now;
502508
if (rms >= SPEECH_LEVEL_THRESHOLD) {
503509
heardSpeechRef.current = true;
@@ -789,7 +795,6 @@ const RoomVoiceAssistant = forwardRef<RoomVoiceAssistantHandle, RoomVoiceAssista
789795
const mimeType = preferredMimeType();
790796
const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
791797
mediaRecorderRef.current = recorder;
792-
startInputMonitoring(stream);
793798

794799
recorder.ondataavailable = (event) => {
795800
if (event.data.size > 0) {
@@ -826,6 +831,11 @@ const RoomVoiceAssistant = forwardRef<RoomVoiceAssistantHandle, RoomVoiceAssista
826831
};
827832

828833
recorder.start();
834+
window.requestAnimationFrame(() => {
835+
if (mediaRecorderRef.current === recorder && recorder.state === 'recording') {
836+
startInputMonitoring(stream);
837+
}
838+
});
829839
autoStopTimerRef.current = window.setTimeout(() => {
830840
stopRecordingWithReason();
831841
}, MAX_RECORDING_MS);

0 commit comments

Comments
 (0)