Skip to content

Commit d450c25

Browse files
committed
Fourth times the charm
1 parent a809e46 commit d450c25

7 files changed

Lines changed: 150 additions & 36 deletions

File tree

backend/routes/auth.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ def _decode_state(state: str) -> dict:
6767
return {}
6868

6969

70+
def _email_log_value(email: str) -> str:
71+
if "@" in email:
72+
return email.split("@")[-1]
73+
return "unknown"
74+
75+
7076
@router.get("/google")
7177
def google_login():
7278
"""Redirect to Google consent screen with identity + calendar scopes."""
@@ -105,16 +111,17 @@ def google_callback(code: str = Query(...), state: str = Query(None)):
105111
# Fetch user info from Google
106112
service = build("oauth2", "v2", credentials=creds)
107113
user_info = service.userinfo().get().execute()
108-
print(f"[auth] step 3: user_info email={user_info.get('email')}")
109114

110115
email = user_info.get("email", "")
116+
email_log_value = _email_log_value(email)
117+
print(f"[auth] step 3: user_info email_domain={email_log_value}")
111118
google_id = user_info.get("id", "")
112119
name = user_info.get("name", "")
113120
avatar_url = user_info.get("picture", "")
114121

115122
# Restrict to .edu accounts
116123
if not email.endswith(".edu"):
117-
print(f"[auth] rejected: not .edu ({email})")
124+
print(f"[auth] rejected: not .edu (email_domain={email_log_value})")
118125
return RedirectResponse(f"{FRONTEND_URL}/?error=invalid_domain")
119126

120127
is_new_user = False

backend/routes/calendar.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
import json
9+
import logging
910
from datetime import datetime, timezone, timedelta
1011

1112
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
@@ -29,6 +30,7 @@
2930
GOOGLE_AVAILABLE = False
3031

3132
router = APIRouter()
33+
logger = logging.getLogger(__name__)
3234

3335

3436
# ── Helpers ───────────────────────────────────────────────────────────────────
@@ -114,8 +116,8 @@ def save_assignments(body: SaveAssignmentsBody):
114116

115117
@router.get("/upcoming/{user_id}")
116118
def get_upcoming(user_id: str):
119+
today = datetime.utcnow().strftime("%Y-%m-%d")
117120
try:
118-
today = datetime.utcnow().strftime("%Y-%m-%d")
119121
rows = table("assignments").select(
120122
"*",
121123
filters={"user_id": f"eq.{user_id}", "due_date": f"gte.{today}"},
@@ -124,6 +126,11 @@ def get_upcoming(user_id: str):
124126
)
125127
return {"assignments": rows or []}
126128
except Exception:
129+
logger.exception(
130+
"Failed to fetch upcoming assignments for user_id=%s today=%s",
131+
user_id,
132+
today,
133+
)
127134
return {"assignments": []}
128135

129136

backend/routes/graph.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
from fastapi import APIRouter
2-
from fastapi.responses import JSONResponse
1+
import logging
2+
3+
from fastapi import APIRouter, HTTPException
34
from pydantic import BaseModel
45
from typing import Optional
56

@@ -9,25 +10,25 @@
910
)
1011

1112
router = APIRouter()
13+
logger = logging.getLogger(__name__)
1214

1315

1416
@router.get("/{user_id}")
1517
def get_user_graph(user_id: str):
1618
try:
1719
return get_graph(user_id)
18-
except Exception:
19-
return {"nodes": [], "edges": [], "stats": {
20-
"total_nodes": 0, "mastered": 0, "learning": 0,
21-
"struggling": 0, "unexplored": 0, "streak": 0, "avg_learning_velocity": 0.0,
22-
}}
20+
except Exception as e:
21+
logger.exception("get_graph failed for user_id=%s", user_id)
22+
raise HTTPException(status_code=500, detail=str(e) or "Failed to fetch graph")
2323

2424

2525
@router.get("/{user_id}/recommendations")
2626
def get_user_recommendations(user_id: str):
2727
try:
2828
return {"recommendations": get_recommendations(user_id)}
29-
except Exception:
30-
return {"recommendations": []}
29+
except Exception as e:
30+
logger.exception("get_recommendations failed for user_id=%s", user_id)
31+
raise HTTPException(status_code=500, detail=str(e) or "Failed to fetch recommendations")
3132

3233

3334
# ── Course endpoints ──────────────────────────────────────────────────────────

backend/tests/test_calendar_routes.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,14 @@ def test_returns_empty_list_when_none(self):
137137
assert r.status_code == 200
138138
assert r.json()["assignments"] == []
139139

140+
def test_returns_empty_on_db_failure(self):
141+
with patch("routes.calendar.table") as t:
142+
t.return_value.select.side_effect = Exception("DB connection error")
143+
r = client.get("/api/calendar/upcoming/user_andres")
144+
145+
assert r.status_code == 200
146+
assert r.json() == {"assignments": []}
147+
140148

141149
# ── POST /api/calendar/suggest-study-blocks ───────────────────────────────────
142150

backend/tests/test_graph_routes.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""
2+
Unit tests for routes/graph.py
3+
"""
4+
5+
from unittest.mock import patch
6+
7+
from fastapi.testclient import TestClient
8+
9+
from main import app
10+
11+
client = TestClient(app)
12+
13+
14+
class TestGraphRoutes:
15+
def test_get_user_graph_returns_500_on_failure(self):
16+
with patch("routes.graph.get_graph", side_effect=Exception("graph failed")):
17+
r = client.get("/api/graph/user_andres")
18+
19+
assert r.status_code == 500
20+
assert r.json() == {"detail": "graph failed"}
21+
22+
def test_get_user_recommendations_returns_500_on_failure(self):
23+
with patch("routes.graph.get_recommendations", side_effect=Exception("recommendations failed")):
24+
r = client.get("/api/graph/user_andres/recommendations")
25+
26+
assert r.status_code == 500
27+
assert r.json() == {"detail": "recommendations failed"}

frontend/src/components/OnboardingFlow.tsx

Lines changed: 87 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -134,22 +134,55 @@ export default function OnboardingFlow({ visible, onClose, onFinish, activeStep,
134134

135135
// Escape key closes
136136
useEffect(() => {
137+
if (!visible) {
138+
if (schoolDebounceRef.current) {
139+
clearTimeout(schoolDebounceRef.current);
140+
schoolDebounceRef.current = null;
141+
}
142+
return;
143+
}
144+
137145
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
138146
window.addEventListener('keydown', handler);
139-
return () => window.removeEventListener('keydown', handler);
140-
}, [onClose]);
147+
return () => {
148+
window.removeEventListener('keydown', handler);
149+
if (schoolDebounceRef.current) {
150+
clearTimeout(schoolDebounceRef.current);
151+
schoolDebounceRef.current = null;
152+
}
153+
};
154+
}, [visible, onClose]);
155+
156+
function handleOptionKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, onSelect: () => void) {
157+
if (event.key === 'Enter' || event.key === ' ') {
158+
event.preventDefault();
159+
onSelect();
160+
}
161+
}
162+
163+
function selectSchool(name: string) {
164+
setFormData(prev => ({ ...prev, school: name }));
165+
setSchoolSuggestions([]);
166+
}
167+
168+
function selectYear(option: string) {
169+
setFormData(prev => ({ ...prev, year: option.toLowerCase() }));
170+
setYearOpen(false);
171+
}
141172

142173
function handleSchoolInput(value: string) {
143174
setFormData(prev => ({ ...prev, school: value }));
144175
if (schoolDebounceRef.current) clearTimeout(schoolDebounceRef.current);
145176
if (value.trim().length < 2) { setSchoolSuggestions([]); return; }
146177
schoolDebounceRef.current = setTimeout(async () => {
147178
try {
148-
const res = await fetch(`http://universities.hipolabs.com/search?name=${encodeURIComponent(value)}&country=United+States`);
179+
const res = await fetch(`https://universities.hipolabs.com/search?name=${encodeURIComponent(value)}&country=United+States`);
149180
const data: { name: string }[] = await res.json();
150181
setSchoolSuggestions(data.slice(0, 10).map(u => u.name));
151182
} catch {
152183
setSchoolSuggestions([]);
184+
} finally {
185+
schoolDebounceRef.current = null;
153186
}
154187
}, 300);
155188
}
@@ -409,27 +442,39 @@ export default function OnboardingFlow({ visible, onClose, onFinish, activeStep,
409442
maxHeight: '192px', overflowY: 'auto',
410443
zIndex: 100,
411444
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
412-
}}>
445+
}} role="listbox" aria-label="School suggestions">
413446
{schoolSuggestions.map((name, i) => (
414-
<div key={i}
415-
onMouseDown={() => {
416-
setFormData(prev => ({ ...prev, school: name }));
417-
setSchoolSuggestions([]);
418-
}}
447+
<button
448+
key={i}
449+
type="button"
450+
role="option"
451+
aria-selected={formData.school === name}
452+
onMouseDown={() => selectSchool(name)}
453+
onKeyDown={e => handleOptionKeyDown(e, () => selectSchool(name))}
419454
style={{
455+
width: '100%',
456+
textAlign: 'left',
420457
padding: '12px 18px',
421458
fontSize: '14px',
422459
color: '#111827',
423460
cursor: 'pointer',
424461
borderBottom: i < schoolSuggestions.length - 1 ? '1px solid rgba(0,0,0,0.06)' : 'none',
425462
fontFamily: "var(--font-dm-sans), 'DM Sans', sans-serif",
426463
transition: 'background 0.15s',
464+
background: formData.school === name ? 'rgba(27,108,66,0.06)' : 'transparent',
465+
borderLeft: 'none',
466+
borderRight: 'none',
467+
borderTop: 'none',
468+
}}
469+
onMouseEnter={e => {
470+
if (formData.school !== name) e.currentTarget.style.background = 'rgba(27,108,66,0.08)';
471+
}}
472+
onMouseLeave={e => {
473+
e.currentTarget.style.background = formData.school === name ? 'rgba(27,108,66,0.06)' : 'transparent';
427474
}}
428-
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(27,108,66,0.08)')}
429-
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
430475
>
431476
{name}
432-
</div>
477+
</button>
433478
))}
434479
</div>
435480
)}
@@ -459,14 +504,18 @@ export default function OnboardingFlow({ visible, onClose, onFinish, activeStep,
459504
maxHeight: '192px', overflowY: 'auto',
460505
zIndex: 100,
461506
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
462-
}}>
507+
}} role="listbox" aria-label="Class year options">
463508
{YEAR_OPTIONS.map((opt, i) => (
464-
<div key={opt}
465-
onMouseDown={() => {
466-
setFormData(prev => ({ ...prev, year: opt.toLowerCase() }));
467-
setYearOpen(false);
468-
}}
509+
<button
510+
key={opt}
511+
type="button"
512+
role="option"
513+
aria-selected={formData.year === opt.toLowerCase()}
514+
onMouseDown={() => selectYear(opt)}
515+
onKeyDown={e => handleOptionKeyDown(e, () => selectYear(opt))}
469516
style={{
517+
width: '100%',
518+
textAlign: 'left',
470519
padding: '12px 18px',
471520
fontSize: '14px',
472521
color: formData.year === opt.toLowerCase() ? '#1B6C42' : '#111827',
@@ -476,12 +525,15 @@ export default function OnboardingFlow({ visible, onClose, onFinish, activeStep,
476525
fontFamily: "var(--font-dm-sans), 'DM Sans', sans-serif",
477526
transition: 'background 0.15s',
478527
background: formData.year === opt.toLowerCase() ? 'rgba(27,108,66,0.06)' : 'transparent',
528+
borderLeft: 'none',
529+
borderRight: 'none',
530+
borderTop: 'none',
479531
}}
480532
onMouseEnter={e => { if (formData.year !== opt.toLowerCase()) e.currentTarget.style.background = 'rgba(27,108,66,0.08)'; }}
481533
onMouseLeave={e => { e.currentTarget.style.background = formData.year === opt.toLowerCase() ? 'rgba(27,108,66,0.06)' : 'transparent'; }}
482534
>
483535
{opt}
484-
</div>
536+
</button>
485537
))}
486538
</div>
487539
)}
@@ -498,7 +550,6 @@ export default function OnboardingFlow({ visible, onClose, onFinish, activeStep,
498550
{(['majors', 'minors'] as const).map(field => {
499551
const isMinor = field === 'minors';
500552
const input = isMinor ? minorInput : majorInput;
501-
const setInput = isMinor ? setMinorInput : setMajorInput;
502553
const suggestions = isMinor ? minorSuggestions : majorSuggestions;
503554
const focused = isMinor ? minorFocused : majorFocused;
504555
const setFocused = isMinor ? setMinorFocused : setMajorFocused;
@@ -530,16 +581,29 @@ export default function OnboardingFlow({ visible, onClose, onFinish, activeStep,
530581
background: '#ffffff', border: '1px solid rgba(0,0,0,0.1)',
531582
borderRadius: '14px', maxHeight: '180px', overflowY: 'auto',
532583
zIndex: 100, boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
533-
}}>
584+
}} role="listbox" aria-label={isMinor ? 'Minor suggestions' : 'Major suggestions'}>
534585
{suggestions.map((s, i) => (
535-
<div key={i} onMouseDown={() => addItem(s)} style={{
586+
<button
587+
key={i}
588+
type="button"
589+
role="option"
590+
aria-selected={items.includes(s)}
591+
onMouseDown={() => addItem(s)}
592+
onKeyDown={e => handleOptionKeyDown(e, () => addItem(s))}
593+
style={{
594+
width: '100%',
595+
textAlign: 'left',
536596
padding: '11px 18px', fontSize: '14px', color: '#111827', cursor: 'pointer',
537597
borderBottom: i < suggestions.length - 1 ? '1px solid rgba(0,0,0,0.06)' : 'none',
538598
fontFamily: "var(--font-dm-sans), 'DM Sans', sans-serif", transition: 'background 0.15s',
599+
background: 'transparent',
600+
borderLeft: 'none',
601+
borderRight: 'none',
602+
borderTop: 'none',
539603
}}
540604
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(27,108,66,0.08)')}
541605
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
542-
>{s}</div>
606+
>{s}</button>
543607
))}
544608
</div>
545609
)}

linkedin-banner.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<!DOCTYPE html>
2-
<html>
2+
<html lang="en">
33
<head>
44
<meta charset="utf-8">
55
<title>Sapling — LinkedIn Banner</title>

0 commit comments

Comments
 (0)