E5K7 commited on
Commit
7004388
·
1 Parent(s): bf04727

feat: implement JWT auth and voice calibration onboarding

Browse files
backend/main.py CHANGED
@@ -14,6 +14,7 @@ from routes.entries import router as entries_router
14
  from routes.trends import router as trends_router
15
  from routes.alerts import router as alerts_router
16
  from routes.chat import router as chat_router
 
17
 
18
  app = FastAPI(
19
  title="InnerVoice API",
@@ -43,28 +44,7 @@ app.include_router(entries_router, prefix="/api")
43
  app.include_router(trends_router, prefix="/api")
44
  app.include_router(alerts_router, prefix="/api")
45
  app.include_router(chat_router, prefix="/api")
46
-
47
- # User creation helper endpoint
48
- from fastapi import Depends
49
- from sqlalchemy.orm import Session
50
- from models.database import get_db, User
51
- from pydantic import BaseModel
52
- import uuid
53
-
54
- class UserCreate(BaseModel):
55
- email: str
56
- name: str
57
-
58
- @app.post("/api/users")
59
- def create_or_get_user(body: UserCreate, db: Session = Depends(get_db)):
60
- user = db.query(User).filter(User.email == body.email).first()
61
- if user:
62
- return {"id": user.id, "email": user.email, "name": user.name, "existed": True}
63
- user = User(id=str(uuid.uuid4()), email=body.email, name=body.name)
64
- db.add(user)
65
- db.commit()
66
- db.refresh(user)
67
- return {"id": user.id, "email": user.email, "name": user.name, "existed": False}
68
 
69
 
70
  @app.get("/api/health")
 
14
  from routes.trends import router as trends_router
15
  from routes.alerts import router as alerts_router
16
  from routes.chat import router as chat_router
17
+ from routes.auth import router as auth_router
18
 
19
  app = FastAPI(
20
  title="InnerVoice API",
 
44
  app.include_router(trends_router, prefix="/api")
45
  app.include_router(alerts_router, prefix="/api")
46
  app.include_router(chat_router, prefix="/api")
47
+ app.include_router(auth_router, prefix="/api")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
 
50
  @app.get("/api/health")
backend/models/database.py CHANGED
@@ -37,6 +37,7 @@ class User(Base):
37
  id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
38
  email = Column(String, unique=True, nullable=False, index=True)
39
  name = Column(String, nullable=False)
 
40
  created_at = Column(DateTime, default=datetime.utcnow)
41
  baseline_pitch = Column(Float, nullable=True)
42
  baseline_energy = Column(Float, nullable=True)
 
37
  id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
38
  email = Column(String, unique=True, nullable=False, index=True)
39
  name = Column(String, nullable=False)
40
+ password_hash = Column(String, nullable=False)
41
  created_at = Column(DateTime, default=datetime.utcnow)
42
  baseline_pitch = Column(Float, nullable=True)
43
  baseline_energy = Column(Float, nullable=True)
backend/requirements.txt CHANGED
@@ -10,8 +10,10 @@ scipy==1.13.0
10
  openai==1.30.1
11
  httpx==0.27.0
12
  aiofiles==23.2.1
 
 
13
  # ML models — comment out if you want faster install without AI analysis
14
  transformers==4.41.2
15
  torch==2.3.0
16
  torchaudio==2.3.0
17
- openai-whisper==20231117
 
10
  openai==1.30.1
11
  httpx==0.27.0
12
  aiofiles==23.2.1
13
+ passlib[bcrypt]==1.7.4
14
+ PyJWT==2.8.0
15
  # ML models — comment out if you want faster install without AI analysis
16
  transformers==4.41.2
17
  torch==2.3.0
18
  torchaudio==2.3.0
19
+ openai-whisper
backend/routes/alerts.py CHANGED
@@ -4,16 +4,17 @@ PUT /api/alerts/{id}/read — mark alert as read
4
  """
5
  from fastapi import APIRouter, Depends, HTTPException
6
  from sqlalchemy.orm import Session
7
- from models.database import get_db, MoodAlert
 
8
 
9
  router = APIRouter()
10
 
11
 
12
  @router.get("/alerts")
13
- def get_alerts(user_id: str, db: Session = Depends(get_db)):
14
  alerts = (
15
  db.query(MoodAlert)
16
- .filter(MoodAlert.user_id == user_id, MoodAlert.is_read == False)
17
  .order_by(MoodAlert.created_at.desc())
18
  .all()
19
  )
 
4
  """
5
  from fastapi import APIRouter, Depends, HTTPException
6
  from sqlalchemy.orm import Session
7
+ from models.database import get_db, MoodAlert, User
8
+ from routes.auth import get_current_user
9
 
10
  router = APIRouter()
11
 
12
 
13
  @router.get("/alerts")
14
+ def get_alerts(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
15
  alerts = (
16
  db.query(MoodAlert)
17
+ .filter(MoodAlert.user_id == current_user.id, MoodAlert.is_read == False)
18
  .order_by(MoodAlert.created_at.desc())
19
  .all()
20
  )
backend/routes/analyze.py CHANGED
@@ -3,7 +3,7 @@ POST /api/analyze — Full voice analysis pipeline
3
  """
4
  import os
5
  import tempfile
6
- from fastapi import APIRouter, UploadFile, File, Form, Depends, HTTPException
7
  from sqlalchemy.orm import Session
8
  from models.database import get_db, VoiceEntry, MoodAlert, User
9
  from services.audio_processor import convert_to_wav, extract_features
@@ -14,6 +14,7 @@ from services.transcriber import transcribe
14
  from services.mood_calculator import calculate_mood_scores
15
  from services.trend_detector import check_trends
16
  from services.gpt_service import generate_insight
 
17
  import uuid
18
 
19
  router = APIRouter()
@@ -22,7 +23,7 @@ router = APIRouter()
22
  @router.post("/analyze")
23
  async def analyze_audio(
24
  audio: UploadFile = File(...),
25
- user_id: str = Form(...),
26
  db: Session = Depends(get_db),
27
  ):
28
  # 1. Save uploaded audio to temp file
@@ -64,6 +65,8 @@ async def analyze_audio(
64
  tempo=features["speech_rate"],
65
  avg_pause=features["avg_pause_duration"],
66
  filler_rate=features["filler_rate"],
 
 
67
  )
68
 
69
  # 7. Generate AI insight
@@ -78,7 +81,7 @@ async def analyze_audio(
78
  # 8. Save voice entry to DB
79
  entry = VoiceEntry(
80
  id=str(uuid.uuid4()),
81
- user_id=user_id,
82
  transcription=transcription,
83
  primary_emotion=emotion,
84
  emotion_confidence=confidence,
@@ -103,7 +106,7 @@ async def analyze_audio(
103
  # 9. Check for trend alerts
104
  all_entries = (
105
  db.query(VoiceEntry)
106
- .filter(VoiceEntry.user_id == user_id)
107
  .order_by(VoiceEntry.created_at)
108
  .all()
109
  )
@@ -116,7 +119,7 @@ async def analyze_audio(
116
  existing = (
117
  db.query(MoodAlert)
118
  .filter(
119
- MoodAlert.user_id == user_id,
120
  MoodAlert.alert_type == alert_data["type"],
121
  MoodAlert.created_at >= cutoff,
122
  )
@@ -124,7 +127,7 @@ async def analyze_audio(
124
  )
125
  if not existing:
126
  alert = MoodAlert(
127
- user_id=user_id,
128
  alert_type=alert_data["type"],
129
  severity=alert_data["severity"],
130
  message=alert_data["message"],
 
3
  """
4
  import os
5
  import tempfile
6
+ from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
7
  from sqlalchemy.orm import Session
8
  from models.database import get_db, VoiceEntry, MoodAlert, User
9
  from services.audio_processor import convert_to_wav, extract_features
 
14
  from services.mood_calculator import calculate_mood_scores
15
  from services.trend_detector import check_trends
16
  from services.gpt_service import generate_insight
17
+ from routes.auth import get_current_user
18
  import uuid
19
 
20
  router = APIRouter()
 
23
  @router.post("/analyze")
24
  async def analyze_audio(
25
  audio: UploadFile = File(...),
26
+ current_user: User = Depends(get_current_user),
27
  db: Session = Depends(get_db),
28
  ):
29
  # 1. Save uploaded audio to temp file
 
65
  tempo=features["speech_rate"],
66
  avg_pause=features["avg_pause_duration"],
67
  filler_rate=features["filler_rate"],
68
+ baseline_energy=current_user.baseline_energy,
69
+ baseline_pitch=current_user.baseline_pitch,
70
  )
71
 
72
  # 7. Generate AI insight
 
81
  # 8. Save voice entry to DB
82
  entry = VoiceEntry(
83
  id=str(uuid.uuid4()),
84
+ user_id=current_user.id,
85
  transcription=transcription,
86
  primary_emotion=emotion,
87
  emotion_confidence=confidence,
 
106
  # 9. Check for trend alerts
107
  all_entries = (
108
  db.query(VoiceEntry)
109
+ .filter(VoiceEntry.user_id == current_user.id)
110
  .order_by(VoiceEntry.created_at)
111
  .all()
112
  )
 
119
  existing = (
120
  db.query(MoodAlert)
121
  .filter(
122
+ MoodAlert.user_id == current_user.id,
123
  MoodAlert.alert_type == alert_data["type"],
124
  MoodAlert.created_at >= cutoff,
125
  )
 
127
  )
128
  if not existing:
129
  alert = MoodAlert(
130
+ user_id=current_user.id,
131
  alert_type=alert_data["type"],
132
  severity=alert_data["severity"],
133
  message=alert_data["message"],
backend/routes/auth.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from datetime import datetime, timedelta
3
+ from typing import Optional
4
+ import jwt
5
+ import bcrypt
6
+ from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
7
+ from fastapi.security import OAuth2PasswordBearer
8
+ from pydantic import BaseModel
9
+ from sqlalchemy.orm import Session
10
+ from models.database import get_db, User
11
+ from services.audio_processor import convert_to_wav, extract_features
12
+ import tempfile
13
+
14
+ router = APIRouter(prefix="/auth", tags=["auth"])
15
+
16
+ SECRET_KEY = os.getenv("SECRET_KEY", "change_me_to_a_random_secret_key_32chars")
17
+ ALGORITHM = "HS256"
18
+ ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
19
+
20
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
21
+
22
+
23
+ class UserCreate(BaseModel):
24
+ name: str
25
+ email: str
26
+ password: str
27
+
28
+ class UserLogin(BaseModel):
29
+ email: str
30
+ password: str
31
+
32
+ class Token(BaseModel):
33
+ access_token: str
34
+ token_type: str
35
+ user_id: str
36
+ name: str
37
+ has_baseline: bool
38
+
39
+
40
+ def verify_password(plain_password, hashed_password):
41
+ return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
42
+
43
+ def get_password_hash(password):
44
+ salt = bcrypt.gensalt()
45
+ return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
46
+
47
+ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
48
+ to_encode = data.copy()
49
+ if expires_delta:
50
+ expire = datetime.utcnow() + expires_delta
51
+ else:
52
+ expire = datetime.utcnow() + timedelta(minutes=15)
53
+ to_encode.update({"exp": expire})
54
+ encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
55
+ return encoded_jwt
56
+
57
+
58
+ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
59
+ credentials_exception = HTTPException(
60
+ status_code=status.HTTP_401_UNAUTHORIZED,
61
+ detail="Could not validate credentials",
62
+ headers={"WWW-Authenticate": "Bearer"},
63
+ )
64
+ try:
65
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
66
+ user_id: str = payload.get("sub")
67
+ if user_id is None:
68
+ raise credentials_exception
69
+ except jwt.PyJWTError:
70
+ raise credentials_exception
71
+ user = db.query(User).filter(User.id == user_id).first()
72
+ if user is None:
73
+ raise credentials_exception
74
+ return user
75
+
76
+
77
+ @router.post("/register", response_model=Token)
78
+ def register(user_in: UserCreate, db: Session = Depends(get_db)):
79
+ # Check if exists
80
+ if db.query(User).filter(User.email == user_in.email).first():
81
+ raise HTTPException(status_code=400, detail="Email already registered")
82
+
83
+ hashed_password = get_password_hash(user_in.password)
84
+ user = User(
85
+ name=user_in.name,
86
+ email=user_in.email,
87
+ password_hash=hashed_password
88
+ )
89
+ db.add(user)
90
+ db.commit()
91
+ db.refresh(user)
92
+
93
+ # Create token
94
+ access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
95
+ access_token = create_access_token(
96
+ data={"sub": user.id}, expires_delta=access_token_expires
97
+ )
98
+ return {
99
+ "access_token": access_token,
100
+ "token_type": "bearer",
101
+ "user_id": user.id,
102
+ "name": user.name,
103
+ "has_baseline": False
104
+ }
105
+
106
+
107
+ @router.post("/login", response_model=Token)
108
+ def login(user_in: UserLogin, db: Session = Depends(get_db)):
109
+ user = db.query(User).filter(User.email == user_in.email).first()
110
+ if not user or not verify_password(user_in.password, user.password_hash):
111
+ raise HTTPException(
112
+ status_code=status.HTTP_401_UNAUTHORIZED,
113
+ detail="Incorrect email or password",
114
+ )
115
+
116
+ access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
117
+ access_token = create_access_token(
118
+ data={"sub": user.id}, expires_delta=access_token_expires
119
+ )
120
+ return {
121
+ "access_token": access_token,
122
+ "token_type": "bearer",
123
+ "user_id": user.id,
124
+ "name": user.name,
125
+ "has_baseline": user.baseline_pitch is not None
126
+ }
127
+
128
+
129
+ @router.post("/baseline")
130
+ async def setup_baseline(
131
+ audio: UploadFile = File(...),
132
+ current_user: User = Depends(get_current_user),
133
+ db: Session = Depends(get_db)
134
+ ):
135
+ """
136
+ Onboarding step: Takes a neutral reading audio clip, extracts features,
137
+ and sets the baseline statistics for the user.
138
+ """
139
+ suffix = ".webm" if audio.content_type == "audio/webm" else ".wav"
140
+ tmp_fd, tmp_path = tempfile.mkstemp(suffix=suffix)
141
+ wav_path = None
142
+ try:
143
+ content = await audio.read()
144
+ with os.fdopen(tmp_fd, "wb") as f:
145
+ f.write(content)
146
+
147
+ wav_path = convert_to_wav(tmp_path)
148
+ features = extract_features(wav_path)
149
+
150
+ # Save to user
151
+ current_user.baseline_pitch = features.get("pitch_mean", 0.0)
152
+ current_user.baseline_energy = features.get("energy_raw", 0.0)
153
+ current_user.baseline_speech_rate = features.get("speech_rate", 0.0)
154
+
155
+ db.commit()
156
+ db.refresh(current_user)
157
+ return {"msg": "Baseline set successfully", "has_baseline": True}
158
+
159
+ finally:
160
+ if os.path.exists(tmp_path):
161
+ os.remove(tmp_path)
162
+ if wav_path and os.path.exists(wav_path):
163
+ os.remove(wav_path)
164
+
backend/routes/chat.py CHANGED
@@ -4,25 +4,26 @@ POST /api/chat — AI companion chat with full user context
4
  from fastapi import APIRouter, Depends, HTTPException
5
  from pydantic import BaseModel
6
  from sqlalchemy.orm import Session
7
- from models.database import get_db, ChatMessage, VoiceEntry
 
8
  from services.gpt_service import chat_response
 
9
  import uuid
10
 
11
  router = APIRouter()
12
 
13
 
14
  class ChatRequest(BaseModel):
15
- user_id: str
16
  message: str
17
- voice_entry_id: str | None = None
18
 
19
 
20
  @router.post("/chat")
21
- def chat(req: ChatRequest, db: Session = Depends(get_db)):
22
  # Fetch recent voice entries for context
23
  recent_entries = (
24
  db.query(VoiceEntry)
25
- .filter(VoiceEntry.user_id == req.user_id)
26
  .order_by(VoiceEntry.created_at.desc())
27
  .limit(30)
28
  .all()
@@ -32,7 +33,7 @@ def chat(req: ChatRequest, db: Session = Depends(get_db)):
32
  # Fetch conversation history
33
  history = (
34
  db.query(ChatMessage)
35
- .filter(ChatMessage.user_id == req.user_id)
36
  .order_by(ChatMessage.created_at.asc())
37
  .limit(40)
38
  .all()
@@ -49,7 +50,7 @@ def chat(req: ChatRequest, db: Session = Depends(get_db)):
49
  # Save user message
50
  user_msg = ChatMessage(
51
  id=str(uuid.uuid4()),
52
- user_id=req.user_id,
53
  role="user",
54
  content=req.message,
55
  voice_entry_id=req.voice_entry_id,
@@ -59,7 +60,7 @@ def chat(req: ChatRequest, db: Session = Depends(get_db)):
59
  # Save assistant response
60
  ai_msg = ChatMessage(
61
  id=str(uuid.uuid4()),
62
- user_id=req.user_id,
63
  role="assistant",
64
  content=response_text,
65
  voice_entry_id=req.voice_entry_id,
@@ -74,10 +75,10 @@ def chat(req: ChatRequest, db: Session = Depends(get_db)):
74
 
75
 
76
  @router.get("/chat/history")
77
- def get_chat_history(user_id: str, db: Session = Depends(get_db)):
78
  messages = (
79
  db.query(ChatMessage)
80
- .filter(ChatMessage.user_id == user_id)
81
  .order_by(ChatMessage.created_at.asc())
82
  .limit(100)
83
  .all()
 
4
  from fastapi import APIRouter, Depends, HTTPException
5
  from pydantic import BaseModel
6
  from sqlalchemy.orm import Session
7
+ from models.database import get_db, ChatMessage, VoiceEntry, User
8
+ from routes.auth import get_current_user
9
  from services.gpt_service import chat_response
10
+ from typing import Optional
11
  import uuid
12
 
13
  router = APIRouter()
14
 
15
 
16
  class ChatRequest(BaseModel):
 
17
  message: str
18
+ voice_entry_id: Optional[str] = None
19
 
20
 
21
  @router.post("/chat")
22
+ def chat(req: ChatRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
23
  # Fetch recent voice entries for context
24
  recent_entries = (
25
  db.query(VoiceEntry)
26
+ .filter(VoiceEntry.user_id == current_user.id)
27
  .order_by(VoiceEntry.created_at.desc())
28
  .limit(30)
29
  .all()
 
33
  # Fetch conversation history
34
  history = (
35
  db.query(ChatMessage)
36
+ .filter(ChatMessage.user_id == current_user.id)
37
  .order_by(ChatMessage.created_at.asc())
38
  .limit(40)
39
  .all()
 
50
  # Save user message
51
  user_msg = ChatMessage(
52
  id=str(uuid.uuid4()),
53
+ user_id=current_user.id,
54
  role="user",
55
  content=req.message,
56
  voice_entry_id=req.voice_entry_id,
 
60
  # Save assistant response
61
  ai_msg = ChatMessage(
62
  id=str(uuid.uuid4()),
63
+ user_id=current_user.id,
64
  role="assistant",
65
  content=response_text,
66
  voice_entry_id=req.voice_entry_id,
 
75
 
76
 
77
  @router.get("/chat/history")
78
+ def get_chat_history(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
79
  messages = (
80
  db.query(ChatMessage)
81
+ .filter(ChatMessage.user_id == current_user.id)
82
  .order_by(ChatMessage.created_at.asc())
83
  .limit(100)
84
  .all()
backend/routes/entries.py CHANGED
@@ -3,7 +3,8 @@ GET /api/entries — fetch voice entries for a user
3
  """
4
  from fastapi import APIRouter, Depends, Query
5
  from sqlalchemy.orm import Session
6
- from models.database import get_db, VoiceEntry
 
7
  from datetime import datetime, timedelta
8
 
9
  router = APIRouter()
@@ -11,14 +12,14 @@ router = APIRouter()
11
 
12
  @router.get("/entries")
13
  def get_entries(
14
- user_id: str,
15
  days: int = Query(default=30, ge=1, le=365),
16
  db: Session = Depends(get_db),
17
  ):
18
  cutoff = datetime.utcnow() - timedelta(days=days)
19
  entries = (
20
  db.query(VoiceEntry)
21
- .filter(VoiceEntry.user_id == user_id, VoiceEntry.created_at >= cutoff)
22
  .order_by(VoiceEntry.created_at)
23
  .all()
24
  )
 
3
  """
4
  from fastapi import APIRouter, Depends, Query
5
  from sqlalchemy.orm import Session
6
+ from models.database import get_db, VoiceEntry, User
7
+ from routes.auth import get_current_user
8
  from datetime import datetime, timedelta
9
 
10
  router = APIRouter()
 
12
 
13
  @router.get("/entries")
14
  def get_entries(
15
+ current_user: User = Depends(get_current_user),
16
  days: int = Query(default=30, ge=1, le=365),
17
  db: Session = Depends(get_db),
18
  ):
19
  cutoff = datetime.utcnow() - timedelta(days=days)
20
  entries = (
21
  db.query(VoiceEntry)
22
+ .filter(VoiceEntry.user_id == current_user.id, VoiceEntry.created_at >= cutoff)
23
  .order_by(VoiceEntry.created_at)
24
  .all()
25
  )
backend/routes/trends.py CHANGED
@@ -3,7 +3,8 @@ GET /api/trends — week-over-week comparisons and pattern insights
3
  """
4
  from fastapi import APIRouter, Depends, Query
5
  from sqlalchemy.orm import Session
6
- from models.database import get_db, VoiceEntry
 
7
  from datetime import datetime, timedelta
8
  from statistics import mean
9
  from collections import Counter
@@ -12,10 +13,10 @@ router = APIRouter()
12
 
13
 
14
  @router.get("/trends")
15
- def get_trends(user_id: str, db: Session = Depends(get_db)):
16
  entries = (
17
  db.query(VoiceEntry)
18
- .filter(VoiceEntry.user_id == user_id)
19
  .order_by(VoiceEntry.created_at)
20
  .all()
21
  )
 
3
  """
4
  from fastapi import APIRouter, Depends, Query
5
  from sqlalchemy.orm import Session
6
+ from models.database import get_db, VoiceEntry, User
7
+ from routes.auth import get_current_user
8
  from datetime import datetime, timedelta
9
  from statistics import mean
10
  from collections import Counter
 
13
 
14
 
15
  @router.get("/trends")
16
+ def get_trends(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
17
  entries = (
18
  db.query(VoiceEntry)
19
+ .filter(VoiceEntry.user_id == current_user.id)
20
  .order_by(VoiceEntry.created_at)
21
  .all()
22
  )
backend/seed_data.py CHANGED
@@ -125,10 +125,16 @@ def seed():
125
  # Create or get demo user
126
  user = db.query(User).filter(User.email == DEMO_USER_EMAIL).first()
127
  if not user:
 
 
128
  user = User(
129
  id=str(uuid.uuid4()),
130
  email=DEMO_USER_EMAIL,
131
  name=DEMO_USER_NAME,
 
 
 
 
132
  )
133
  db.add(user)
134
  db.commit()
 
125
  # Create or get demo user
126
  user = db.query(User).filter(User.email == DEMO_USER_EMAIL).first()
127
  if not user:
128
+ from routes.auth import get_password_hash
129
+ hashed_pwd = get_password_hash("password")
130
  user = User(
131
  id=str(uuid.uuid4()),
132
  email=DEMO_USER_EMAIL,
133
  name=DEMO_USER_NAME,
134
+ password_hash=hashed_pwd,
135
+ baseline_pitch=150.0,
136
+ baseline_energy=0.03,
137
+ baseline_speech_rate=100.0,
138
  )
139
  db.add(user)
140
  db.commit()
backend/services/mood_calculator.py CHANGED
@@ -12,11 +12,22 @@ def calculate_mood_scores(
12
  tempo: float,
13
  avg_pause: float,
14
  filler_rate: float,
 
 
15
  ) -> dict:
16
  """Calculate Energy, Calmness, Mood, and Clarity scores (0-100)."""
17
 
18
  # ── Energy Score ──────────────────────────────────────────────────────────
19
- energy_score = min(100, int(energy * 5000))
 
 
 
 
 
 
 
 
 
20
  if emotion == "happy":
21
  energy_score = min(100, energy_score + 15)
22
  if emotion == "sad":
 
12
  tempo: float,
13
  avg_pause: float,
14
  filler_rate: float,
15
+ baseline_energy: float = None,
16
+ baseline_pitch: float = None,
17
  ) -> dict:
18
  """Calculate Energy, Calmness, Mood, and Clarity scores (0-100)."""
19
 
20
  # ── Energy Score ──────────────────────────────────────────────────────────
21
+ if baseline_energy and baseline_energy > 0:
22
+ # Calculate relative to baseline
23
+ ratio = energy / baseline_energy
24
+ # If ratio is 1.0 (baseline), score is 50.
25
+ # Max out at 2x baseline
26
+ energy_score = min(100, int((ratio) * 50))
27
+ else:
28
+ # Fallback to absolute
29
+ energy_score = min(100, int(energy * 5000))
30
+
31
  if emotion == "happy":
32
  energy_score = min(100, energy_score + 15)
33
  if emotion == "sad":
frontend/app/chat/page.tsx CHANGED
@@ -32,7 +32,7 @@ export default function ChatPage() {
32
  if (isDemoMode) {
33
  setMessages(DEMO_CHAT_HISTORY as Message[]);
34
  } else {
35
- const hist = await api.getChatHistory(userId);
36
  setMessages(hist as Message[]);
37
  }
38
  } catch (e) {
@@ -68,7 +68,7 @@ export default function ChatPage() {
68
  setLoading(false);
69
  });
70
  } else {
71
- const res = await api.chat(userId, text);
72
  setMessages(prev => [...prev, { id: Date.now().toString(), role: "assistant", content: res.response }]);
73
  }
74
  } catch (e) {
 
32
  if (isDemoMode) {
33
  setMessages(DEMO_CHAT_HISTORY as Message[]);
34
  } else {
35
+ const hist = await api.getChatHistory();
36
  setMessages(hist as Message[]);
37
  }
38
  } catch (e) {
 
68
  setLoading(false);
69
  });
70
  } else {
71
+ const res = await api.chat(text);
72
  setMessages(prev => [...prev, { id: Date.now().toString(), role: "assistant", content: res.response }]);
73
  }
74
  } catch (e) {
frontend/app/dashboard/page.tsx CHANGED
@@ -27,9 +27,9 @@ export default function DashboardPage() {
27
  setTrends(DEMO_TRENDS);
28
  } else {
29
  const [eRes, aRes, tRes] = await Promise.all([
30
- api.getEntries(userId, 14),
31
- api.getAlerts(userId),
32
- api.getTrends(userId),
33
  ]);
34
  setEntries(eRes);
35
  setAlerts(aRes);
 
27
  setTrends(DEMO_TRENDS);
28
  } else {
29
  const [eRes, aRes, tRes] = await Promise.all([
30
+ api.getEntries(14),
31
+ api.getAlerts(),
32
+ api.getTrends(),
33
  ]);
34
  setEntries(eRes);
35
  setAlerts(aRes);
frontend/app/layout.tsx CHANGED
@@ -4,6 +4,8 @@ import "./globals.css";
4
  import Navbar from "@/components/Navbar";
5
  import DemoModeBanner from "@/components/DemoModeBanner";
6
 
 
 
7
  const inter = Inter({ subsets: ["latin"] });
8
 
9
  export const metadata: Metadata = {
@@ -24,9 +26,11 @@ export default function RootLayout({
24
  <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
25
  </head>
26
  <body className={`${inter.className} bg-[#0f0a1e] text-white min-h-screen`}>
27
- <DemoModeBanner />
28
- <Navbar />
29
- <main className="md:ml-64 min-h-screen pt-10 md:pt-0">{children}</main>
 
 
30
  </body>
31
  </html>
32
  );
 
4
  import Navbar from "@/components/Navbar";
5
  import DemoModeBanner from "@/components/DemoModeBanner";
6
 
7
+ import { AuthProvider } from "@/contexts/AuthContext";
8
+
9
  const inter = Inter({ subsets: ["latin"] });
10
 
11
  export const metadata: Metadata = {
 
26
  <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
27
  </head>
28
  <body className={`${inter.className} bg-[#0f0a1e] text-white min-h-screen`}>
29
+ <AuthProvider>
30
+ <DemoModeBanner />
31
+ <Navbar />
32
+ <main className="md:ml-64 min-h-screen pt-10 md:pt-0">{children}</main>
33
+ </AuthProvider>
34
  </body>
35
  </html>
36
  );
frontend/app/login/page.tsx ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useState } from "react";
3
+ import Link from "next/link";
4
+ import { useRouter } from "next/navigation";
5
+ import { useAuth } from "@/contexts/AuthContext";
6
+ import { apiPost } from "@/lib/api";
7
+
8
+ export default function LoginPage() {
9
+ const [email, setEmail] = useState("");
10
+ const [password, setPassword] = useState("");
11
+ const [error, setError] = useState("");
12
+ const [loading, setLoading] = useState(false);
13
+ const router = useRouter();
14
+ const { loginState } = useAuth();
15
+
16
+ const handleSubmit = async (e: React.FormEvent) => {
17
+ e.preventDefault();
18
+ setLoading(true);
19
+ setError("");
20
+
21
+ try {
22
+ const data = await apiPost<{ access_token: string; user_id: string; name: string; has_baseline: boolean }>(
23
+ "/api/auth/login",
24
+ { email, password }
25
+ );
26
+
27
+ loginState(data.access_token, {
28
+ id: data.user_id,
29
+ name: data.name,
30
+ hasBaseline: data.has_baseline,
31
+ });
32
+
33
+ if (!data.has_baseline) {
34
+ router.push("/onboarding");
35
+ } else {
36
+ router.push("/dashboard");
37
+ }
38
+ } catch (err: any) {
39
+ setError("Invalid email or password");
40
+ } finally {
41
+ setLoading(false);
42
+ }
43
+ };
44
+
45
+ return (
46
+ <div className="min-h-screen flex items-center justify-center p-6 relative z-10 md:-ml-64">
47
+ <div className="w-full max-w-sm glass-card p-8 animate-slide-up">
48
+ <div className="text-center mb-8">
49
+ <div className="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-600 to-teal-500 flex items-center justify-center text-2xl shadow-glow-purple mx-auto mb-4">
50
+ 🎵
51
+ </div>
52
+ <h1 className="text-2xl font-bold">Welcome Back</h1>
53
+ <p className="text-sm text-white/50 mt-2">Sign in to InnerVoice</p>
54
+ </div>
55
+
56
+ <form onSubmit={handleSubmit} className="space-y-4">
57
+ <div>
58
+ <label className="block text-xs font-semibold uppercase text-white/50 mb-1.5 ml-1">Email</label>
59
+ <input
60
+ type="email"
61
+ required
62
+ value={email}
63
+ onChange={(e) => setEmail(e.target.value)}
64
+ className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-white/20 outline-none focus:border-purple-500/50 transition-colors"
65
+ placeholder="you@example.com"
66
+ />
67
+ </div>
68
+ <div>
69
+ <label className="block text-xs font-semibold uppercase text-white/50 mb-1.5 ml-1">Password</label>
70
+ <input
71
+ type="password"
72
+ required
73
+ value={password}
74
+ onChange={(e) => setPassword(e.target.value)}
75
+ className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-white/20 outline-none focus:border-purple-500/50 transition-colors"
76
+ placeholder="••••••••"
77
+ />
78
+ </div>
79
+
80
+ {error && <div className="text-red-400 text-sm text-center py-2">{error}</div>}
81
+
82
+ <button
83
+ type="submit"
84
+ disabled={loading}
85
+ className="w-full py-3 mt-4 bg-gradient-to-r from-purple-600 to-teal-500 font-semibold rounded-xl hover:opacity-90 transition-opacity text-white disabled:opacity-50"
86
+ >
87
+ {loading ? "Signing in..." : "Sign In"}
88
+ </button>
89
+ </form>
90
+
91
+ <p className="text-center text-sm text-white/40 mt-6">
92
+ Don't have an account?{" "}
93
+ <Link href="/signup" className="text-purple-400 hover:text-purple-300">
94
+ Sign up
95
+ </Link>
96
+ </p>
97
+ </div>
98
+ </div>
99
+ );
100
+ }
frontend/app/onboarding/page.tsx ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useState, useRef, useEffect } from "react";
3
+ import { useRouter } from "next/navigation";
4
+ import { useAuth } from "@/contexts/AuthContext";
5
+ import { useAudioRecorder } from "@/hooks/useAudioRecorder";
6
+ import { api } from "@/lib/api";
7
+ import WaveformVisualizer from "@/components/WaveformVisualizer";
8
+ import CircularProgress from "@/components/CircularProgress";
9
+ import { motion } from "framer-motion";
10
+
11
+ export default function OnboardingPage() {
12
+ const router = useRouter();
13
+ const { user, loginState, token } = useAuth();
14
+
15
+ const {
16
+ state,
17
+ seconds,
18
+ startRecording,
19
+ stopRecording,
20
+ audioBlob,
21
+ analyserNode,
22
+ reset
23
+ } = useAudioRecorder(15);
24
+
25
+ const isRecording = state === "recording";
26
+
27
+ const [isSubmitting, setIsSubmitting] = useState(false);
28
+ const [error, setError] = useState<string | null>(null);
29
+
30
+ useEffect(() => {
31
+ if (seconds >= 15 && isRecording) {
32
+ stopRecording();
33
+ }
34
+ }, [seconds, isRecording, stopRecording]);
35
+
36
+ const handleSubmit = async () => {
37
+ if (!audioBlob) return;
38
+ setIsSubmitting(true);
39
+ setError(null);
40
+
41
+ try {
42
+ const res = await api.setBaseline(audioBlob);
43
+ if (res.has_baseline && user && token) {
44
+ // Update user state globally
45
+ loginState(token, { ...user, hasBaseline: true });
46
+ router.push("/dashboard");
47
+ }
48
+ } catch (err: any) {
49
+ setError("Failed to process calibration audio. Please try again.");
50
+ } finally {
51
+ setIsSubmitting(false);
52
+ }
53
+ };
54
+
55
+ return (
56
+ <div className="max-w-3xl mx-auto p-6 md:p-10 relative mt-20 md:mt-0">
57
+ <div className="mb-10 animate-fade-in text-center">
58
+ <h1 className="text-3xl font-bold bg-gradient-to-r from-teal-400 to-purple-400 bg-clip-text text-transparent">
59
+ Voice Calibration
60
+ </h1>
61
+ <p className="text-white/60 mt-2 max-w-lg mx-auto">
62
+ Before we begin, we need a baseline of your "normal" voice. This helps
63
+ our AI detect subtle shifts in your mood relative to your personal baseline.
64
+ </p>
65
+ </div>
66
+
67
+ <div className="glass-card p-6 md:p-10 mb-8 max-w-2xl mx-auto">
68
+ <div className="bg-black/30 p-6 rounded-2xl border border-white/5 mb-8">
69
+ <p className="text-xs uppercase text-white/50 mb-4 font-semibold tracking-wider">Please read this short text aloud:</p>
70
+ <p className="text-lg md:text-xl font-medium leading-relaxed">
71
+ "I am recording this to calibrate my voice. The quick brown fox jumps over the lazy dog. It is a beautiful day outside, and I am feeling calm and neutral."
72
+ </p>
73
+ </div>
74
+
75
+ <div className="flex flex-col items-center">
76
+ <div className="h-24 w-full mb-8 relative">
77
+ {!isRecording && !audioBlob ? (
78
+ <div className="absolute inset-0 flex items-center justify-center text-white/20 text-sm">
79
+ Voice visualizer will appear here...
80
+ </div>
81
+ ) : (
82
+ <WaveformVisualizer analyserNode={analyserNode} isActive={isRecording} />
83
+ )}
84
+ </div>
85
+
86
+ {!audioBlob ? (
87
+ <div className="relative w-24 h-24">
88
+ <CircularProgress progress={(seconds / 15) * 100} />
89
+ <button
90
+ onClick={isRecording ? stopRecording : startRecording}
91
+ className={`absolute inset-0 m-auto w-16 h-16 rounded-full flex items-center justify-center transition-all ${
92
+ isRecording
93
+ ? "bg-red-500/20 text-red-400 hover:bg-red-500/30"
94
+ : "bg-purple-500 hover:bg-purple-400 hover:shadow-glow-purple text-white shadow-lg"
95
+ }`}
96
+ >
97
+ {isRecording ? (
98
+ <div className="w-5 h-5 bg-red-400 rounded-sm animate-pulse" />
99
+ ) : (
100
+ <svg className="w-6 h-6 ml-1" fill="currentColor" viewBox="0 0 24 24"><path d="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/></svg>
101
+ )}
102
+ </button>
103
+ {isRecording && (
104
+ <div className="absolute -bottom-8 left-0 right-0 text-center text-sm font-medium text-red-400">
105
+ {Math.floor(seconds)}s / 15s
106
+ </div>
107
+ )}
108
+ </div>
109
+ ) : (
110
+ <motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="flex gap-4">
111
+ <button
112
+ disabled={isSubmitting}
113
+ onClick={() => reset()}
114
+ className="px-6 py-3 rounded-xl bg-white/5 hover:bg-white/10 transition-colors font-medium border border-white/10 disabled:opacity-50"
115
+ >
116
+ Retake
117
+ </button>
118
+ <button
119
+ onClick={handleSubmit}
120
+ disabled={isSubmitting}
121
+ className="px-8 py-3 rounded-xl bg-gradient-to-r from-purple-600 to-teal-500 font-medium hover:opacity-90 transition-opacity shadow-glow-purple disabled:opacity-50 flex items-center gap-2"
122
+ >
123
+ {isSubmitting ? (
124
+ <>
125
+ <svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
126
+ Calibrating...
127
+ </>
128
+ ) : "Set Baseline"}
129
+ </button>
130
+ </motion.div>
131
+ )}
132
+
133
+ {error && <div className="mt-6 text-red-400 text-sm">{error}</div>}
134
+ </div>
135
+ </div>
136
+ </div>
137
+ );
138
+ }
frontend/app/record/page.tsx CHANGED
@@ -55,7 +55,7 @@ export default function RecordPage() {
55
  setAnalyzing(false);
56
  });
57
  } else {
58
- const res = await api.analyzeAudio(audioBlob, userId);
59
  setResult(res);
60
  setAnalyzing(false);
61
  }
 
55
  setAnalyzing(false);
56
  });
57
  } else {
58
+ const res = await api.analyzeAudio(audioBlob);
59
  setResult(res);
60
  setAnalyzing(false);
61
  }
frontend/app/signup/page.tsx ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useState } from "react";
3
+ import Link from "next/link";
4
+ import { useRouter } from "next/navigation";
5
+ import { useAuth } from "@/contexts/AuthContext";
6
+ import { apiPost } from "@/lib/api";
7
+
8
+ export default function SignupPage() {
9
+ const [name, setName] = useState("");
10
+ const [email, setEmail] = useState("");
11
+ const [password, setPassword] = useState("");
12
+ const [error, setError] = useState("");
13
+ const [loading, setLoading] = useState(false);
14
+ const router = useRouter();
15
+ const { loginState } = useAuth();
16
+
17
+ const handleSubmit = async (e: React.FormEvent) => {
18
+ e.preventDefault();
19
+ setLoading(true);
20
+ setError("");
21
+
22
+ try {
23
+ const data = await apiPost<{ access_token: string; user_id: string; name: string; has_baseline: boolean }>(
24
+ "/api/auth/register",
25
+ { name, email, password }
26
+ );
27
+
28
+ loginState(data.access_token, {
29
+ id: data.user_id,
30
+ name: data.name,
31
+ hasBaseline: data.has_baseline,
32
+ });
33
+
34
+ router.push("/onboarding");
35
+ } catch (err: any) {
36
+ setError("Registration failed, email might be in use.");
37
+ } finally {
38
+ setLoading(false);
39
+ }
40
+ };
41
+
42
+ return (
43
+ <div className="min-h-screen flex items-center justify-center p-6 relative z-10 md:-ml-64">
44
+ <div className="w-full max-w-sm glass-card p-8 animate-slide-up">
45
+ <div className="text-center mb-8">
46
+ <div className="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-600 to-teal-500 flex items-center justify-center text-2xl shadow-glow-purple mx-auto mb-4">
47
+
48
+ </div>
49
+ <h1 className="text-2xl font-bold">Join InnerVoice</h1>
50
+ <p className="text-sm text-white/50 mt-2">Start your emotional wellness journey</p>
51
+ </div>
52
+
53
+ <form onSubmit={handleSubmit} className="space-y-4">
54
+ <div>
55
+ <label className="block text-xs font-semibold uppercase text-white/50 mb-1.5 ml-1">Name</label>
56
+ <input
57
+ type="text"
58
+ required
59
+ value={name}
60
+ onChange={(e) => setName(e.target.value)}
61
+ className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-white/20 outline-none focus:border-purple-500/50 transition-colors"
62
+ placeholder="Your Name"
63
+ />
64
+ </div>
65
+ <div>
66
+ <label className="block text-xs font-semibold uppercase text-white/50 mb-1.5 ml-1">Email</label>
67
+ <input
68
+ type="email"
69
+ required
70
+ value={email}
71
+ onChange={(e) => setEmail(e.target.value)}
72
+ className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-white/20 outline-none focus:border-purple-500/50 transition-colors"
73
+ placeholder="you@example.com"
74
+ />
75
+ </div>
76
+ <div>
77
+ <label className="block text-xs font-semibold uppercase text-white/50 mb-1.5 ml-1">Password</label>
78
+ <input
79
+ type="password"
80
+ required
81
+ value={password}
82
+ onChange={(e) => setPassword(e.target.value)}
83
+ className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-white/20 outline-none focus:border-purple-500/50 transition-colors"
84
+ placeholder="••••••••"
85
+ />
86
+ </div>
87
+
88
+ {error && <div className="text-red-400 text-sm text-center py-2">{error}</div>}
89
+
90
+ <button
91
+ type="submit"
92
+ disabled={loading}
93
+ className="w-full py-3 mt-4 bg-gradient-to-r from-purple-600 to-teal-500 font-semibold rounded-xl hover:opacity-90 transition-opacity text-white disabled:opacity-50"
94
+ >
95
+ {loading ? "Creating Account..." : "Create Account"}
96
+ </button>
97
+ </form>
98
+
99
+ <p className="text-center text-sm text-white/40 mt-6">
100
+ Already have an account?{" "}
101
+ <Link href="/login" className="text-purple-400 hover:text-purple-300">
102
+ Sign in
103
+ </Link>
104
+ </p>
105
+ </div>
106
+ </div>
107
+ );
108
+ }
frontend/app/timeline/page.tsx CHANGED
@@ -25,8 +25,8 @@ export default function TimelinePage() {
25
  setTrends(DEMO_TRENDS);
26
  } else {
27
  const [eRes, tRes] = await Promise.all([
28
- api.getEntries(userId, 30),
29
- api.getTrends(userId),
30
  ]);
31
  setEntries(eRes);
32
  setTrends(tRes);
 
25
  setTrends(DEMO_TRENDS);
26
  } else {
27
  const [eRes, tRes] = await Promise.all([
28
+ api.getEntries(30),
29
+ api.getTrends(),
30
  ]);
31
  setEntries(eRes);
32
  setTrends(tRes);
frontend/components/Navbar.tsx CHANGED
@@ -2,6 +2,7 @@
2
  import Link from "next/link";
3
  import { usePathname } from "next/navigation";
4
  import { useDemoMode } from "@/hooks/useDemoMode";
 
5
 
6
  const navItems = [
7
  { href: "/dashboard", label: "Dashboard", icon: "🏠" },
@@ -14,6 +15,7 @@ const navItems = [
14
  export default function Navbar() {
15
  const pathname = usePathname();
16
  const { isDemoMode, toggleDemoMode } = useDemoMode();
 
17
 
18
  return (
19
  <>
@@ -56,8 +58,17 @@ export default function Navbar() {
56
  })}
57
  </div>
58
 
59
- {/* Demo mode toggle */}
60
- <div className="p-4 border-t border-white/5">
 
 
 
 
 
 
 
 
 
61
  <button
62
  onClick={toggleDemoMode}
63
  className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all text-sm ${
 
2
  import Link from "next/link";
3
  import { usePathname } from "next/navigation";
4
  import { useDemoMode } from "@/hooks/useDemoMode";
5
+ import { useAuth } from "@/contexts/AuthContext";
6
 
7
  const navItems = [
8
  { href: "/dashboard", label: "Dashboard", icon: "🏠" },
 
15
  export default function Navbar() {
16
  const pathname = usePathname();
17
  const { isDemoMode, toggleDemoMode } = useDemoMode();
18
+ const { user, logout } = useAuth();
19
 
20
  return (
21
  <>
 
58
  })}
59
  </div>
60
 
61
+ {/* Demo mode & Auth */}
62
+ <div className="p-4 border-t border-white/5 space-y-2">
63
+ {user && !isDemoMode && (
64
+ <button
65
+ onClick={logout}
66
+ className="w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all text-sm text-white/40 hover:text-red-400 hover:bg-white/5"
67
+ >
68
+ <span>👋</span>
69
+ <span className="font-medium">Sign Out</span>
70
+ </button>
71
+ )}
72
  <button
73
  onClick={toggleDemoMode}
74
  className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all text-sm ${
frontend/contexts/AuthContext.tsx ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { createContext, useContext, useState, useEffect, ReactNode } from "react";
3
+ import { useRouter, usePathname } from "next/navigation";
4
+ import { useDemoMode } from "@/hooks/useDemoMode";
5
+
6
+ interface User {
7
+ id: string;
8
+ name: string;
9
+ hasBaseline: boolean;
10
+ }
11
+
12
+ interface AuthContextType {
13
+ user: User | null;
14
+ token: string | null;
15
+ loginState: (token: string, user: User) => void;
16
+ logout: () => void;
17
+ isLoading: boolean;
18
+ }
19
+
20
+ const AuthContext = createContext<AuthContextType>({
21
+ user: null,
22
+ token: null,
23
+ loginState: () => {},
24
+ logout: () => {},
25
+ isLoading: true,
26
+ });
27
+
28
+ export function AuthProvider({ children }: { children: ReactNode }) {
29
+ const [user, setUser] = useState<User | null>(null);
30
+ const [token, setToken] = useState<string | null>(null);
31
+ const [isLoading, setIsLoading] = useState(true);
32
+ const router = useRouter();
33
+ const pathname = usePathname();
34
+ const { isDemoMode } = useDemoMode();
35
+
36
+ useEffect(() => {
37
+ // Load auth from local storage on mount
38
+ const savedToken = localStorage.getItem("token");
39
+ const savedUser = localStorage.getItem("user");
40
+
41
+ if (savedToken && savedUser) {
42
+ setToken(savedToken);
43
+ setUser(JSON.parse(savedUser));
44
+ }
45
+ setIsLoading(false);
46
+ }, []);
47
+
48
+ // Route guarding
49
+ useEffect(() => {
50
+ if (isLoading) return;
51
+
52
+ const publicPaths = ["/", "/login", "/signup"];
53
+ const isPublic = publicPaths.includes(pathname);
54
+
55
+ if (isDemoMode) {
56
+ // Allow full access in Demo Mode
57
+ return;
58
+ }
59
+
60
+ if (!user && !isPublic) {
61
+ router.push("/login");
62
+ } else if (user && isPublic && pathname !== "/") {
63
+ if (!user.hasBaseline) {
64
+ router.push("/onboarding");
65
+ } else {
66
+ router.push("/dashboard");
67
+ }
68
+ } else if (user && !user.hasBaseline && pathname !== "/onboarding") {
69
+ router.push("/onboarding");
70
+ }
71
+ }, [user, pathname, isLoading, router, isDemoMode]);
72
+
73
+ const loginState = (newToken: string, newUser: User) => {
74
+ setToken(newToken);
75
+ setUser(newUser);
76
+ localStorage.setItem("token", newToken);
77
+ localStorage.setItem("user", JSON.stringify(newUser));
78
+ };
79
+
80
+ const logout = () => {
81
+ setToken(null);
82
+ setUser(null);
83
+ localStorage.removeItem("token");
84
+ localStorage.removeItem("user");
85
+ router.push("/");
86
+ };
87
+
88
+ return (
89
+ <AuthContext.Provider value={{ user, token, loginState, logout, isLoading }}>
90
+ {children}
91
+ </AuthContext.Provider>
92
+ );
93
+ }
94
+
95
+ export function useAuth() {
96
+ return useContext(AuthContext);
97
+ }
frontend/lib/api.ts CHANGED
@@ -2,8 +2,16 @@
2
 
3
  const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
4
 
 
 
 
 
 
 
5
  export async function apiGet<T>(path: string): Promise<T> {
6
- const res = await fetch(`${API_URL}${path}`);
 
 
7
  if (!res.ok) throw new Error(`API Error ${res.status}: ${await res.text()}`);
8
  return res.json();
9
  }
@@ -11,7 +19,7 @@ export async function apiGet<T>(path: string): Promise<T> {
11
  export async function apiPost<T>(path: string, body: unknown): Promise<T> {
12
  const res = await fetch(`${API_URL}${path}`, {
13
  method: "POST",
14
- headers: { "Content-Type": "application/json" },
15
  body: JSON.stringify(body),
16
  });
17
  if (!res.ok) throw new Error(`API Error ${res.status}: ${await res.text()}`);
@@ -19,7 +27,10 @@ export async function apiPost<T>(path: string, body: unknown): Promise<T> {
19
  }
20
 
21
  export async function apiPut<T>(path: string): Promise<T> {
22
- const res = await fetch(`${API_URL}${path}`, { method: "PUT" });
 
 
 
23
  if (!res.ok) throw new Error(`API Error ${res.status}: ${await res.text()}`);
24
  return res.json();
25
  }
@@ -74,38 +85,61 @@ export interface AnalyzeResult {
74
  }
75
 
76
  export const api = {
77
- getEntries: (userId: string, days = 30) =>
78
- apiGet<VoiceEntry[]>(`/api/entries?user_id=${userId}&days=${days}`),
79
 
80
- getTrends: (userId: string) =>
81
- apiGet<TrendsData>(`/api/trends?user_id=${userId}`),
82
 
83
- getAlerts: (userId: string) =>
84
- apiGet<MoodAlert[]>(`/api/alerts?user_id=${userId}`),
85
 
86
  markAlertRead: (alertId: string) =>
87
  apiPut<{ success: boolean }>(`/api/alerts/${alertId}/read`),
88
 
89
- chat: (userId: string, message: string) =>
90
- apiPost<{ response: string }>("/api/chat", { user_id: userId, message }),
91
 
92
- getChatHistory: (userId: string) =>
93
  apiGet<Array<{ id: string; role: string; content: string; created_at: string }>>(
94
- `/api/chat/history?user_id=${userId}`
95
  ),
96
 
97
- createUser: (email: string, name: string) =>
98
- apiPost<{ id: string; email: string; name: string }>("/api/users", { email, name }),
99
-
100
- analyzeAudio: async (audioBlob: Blob, userId: string): Promise<AnalyzeResult> => {
101
  const formData = new FormData();
102
  formData.append("audio", audioBlob, "recording.webm");
103
- formData.append("user_id", userId);
 
 
 
 
 
 
104
  const res = await fetch(`${API_URL}/api/analyze`, {
105
  method: "POST",
 
106
  body: formData,
107
  });
108
  if (!res.ok) throw new Error(`Analyze error ${res.status}`);
109
  return res.json();
110
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  };
 
2
 
3
  const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
4
 
5
+ function getAuthHeader(): Record<string, string> {
6
+ if (typeof window === "undefined") return {};
7
+ const token = localStorage.getItem("token");
8
+ return token ? { Authorization: `Bearer ${token}` } : {};
9
+ }
10
+
11
  export async function apiGet<T>(path: string): Promise<T> {
12
+ const res = await fetch(`${API_URL}${path}`, {
13
+ headers: { ...getAuthHeader() },
14
+ });
15
  if (!res.ok) throw new Error(`API Error ${res.status}: ${await res.text()}`);
16
  return res.json();
17
  }
 
19
  export async function apiPost<T>(path: string, body: unknown): Promise<T> {
20
  const res = await fetch(`${API_URL}${path}`, {
21
  method: "POST",
22
+ headers: { "Content-Type": "application/json", ...getAuthHeader() },
23
  body: JSON.stringify(body),
24
  });
25
  if (!res.ok) throw new Error(`API Error ${res.status}: ${await res.text()}`);
 
27
  }
28
 
29
  export async function apiPut<T>(path: string): Promise<T> {
30
+ const res = await fetch(`${API_URL}${path}`, {
31
+ method: "PUT",
32
+ headers: { ...getAuthHeader() },
33
+ });
34
  if (!res.ok) throw new Error(`API Error ${res.status}: ${await res.text()}`);
35
  return res.json();
36
  }
 
85
  }
86
 
87
  export const api = {
88
+ getEntries: (days = 30) =>
89
+ apiGet<VoiceEntry[]>(`/api/entries?days=${days}`),
90
 
91
+ getTrends: () =>
92
+ apiGet<TrendsData>(`/api/trends`),
93
 
94
+ getAlerts: () =>
95
+ apiGet<MoodAlert[]>(`/api/alerts`),
96
 
97
  markAlertRead: (alertId: string) =>
98
  apiPut<{ success: boolean }>(`/api/alerts/${alertId}/read`),
99
 
100
+ chat: (message: string) =>
101
+ apiPost<{ response: string }>("/api/chat", { message }),
102
 
103
+ getChatHistory: () =>
104
  apiGet<Array<{ id: string; role: string; content: string; created_at: string }>>(
105
+ `/api/chat/history`
106
  ),
107
 
108
+ analyzeAudio: async (audioBlob: Blob): Promise<AnalyzeResult> => {
 
 
 
109
  const formData = new FormData();
110
  formData.append("audio", audioBlob, "recording.webm");
111
+
112
+ const headers: Record<string, string> = {};
113
+ if (typeof window !== "undefined") {
114
+ const token = localStorage.getItem("token");
115
+ if (token) headers["Authorization"] = `Bearer ${token}`;
116
+ }
117
+
118
  const res = await fetch(`${API_URL}/api/analyze`, {
119
  method: "POST",
120
+ headers,
121
  body: formData,
122
  });
123
  if (!res.ok) throw new Error(`Analyze error ${res.status}`);
124
  return res.json();
125
  },
126
+
127
+ setBaseline: async (audioBlob: Blob): Promise<{msg: string, has_baseline: boolean}> => {
128
+ const formData = new FormData();
129
+ formData.append("audio", audioBlob, "calibration.webm");
130
+
131
+ const headers: Record<string, string> = {};
132
+ if (typeof window !== "undefined") {
133
+ const token = localStorage.getItem("token");
134
+ if (token) headers["Authorization"] = `Bearer ${token}`;
135
+ }
136
+
137
+ const res = await fetch(`${API_URL}/api/auth/baseline`, {
138
+ method: "POST",
139
+ headers,
140
+ body: formData,
141
+ });
142
+ if (!res.ok) throw new Error(`Baseline error ${res.status}`);
143
+ return res.json();
144
+ }
145
  };