From 2646f5f01354851d89bb087f91a3d4d2d92d4819 Mon Sep 17 00:00:00 2001 From: YugDalwadi Date: Sat, 28 Feb 2026 02:47:21 +0530 Subject: [PATCH 1/4] feat: backend ban gaya?? --- backend/.env.example | 18 + backend/.gitignore | 4 + backend/__pycache__/main.cpython-312.pyc | Bin 0 -> 443 bytes backend/app/__init__.py | 0 .../app/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 178 bytes .../app/__pycache__/config.cpython-312.pyc | Bin 0 -> 1339 bytes .../app/__pycache__/database.cpython-312.pyc | Bin 0 -> 1774 bytes .../logging_config.cpython-312.pyc | Bin 0 -> 1948 bytes backend/app/__pycache__/main.cpython-312.pyc | Bin 0 -> 3705 bytes backend/app/config.py | 30 + backend/app/database.py | 33 ++ backend/app/logging_config.py | 33 ++ backend/app/main.py | 75 +++ backend/app/models/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 185 bytes .../__pycache__/calendar.cpython-312.pyc | Bin 0 -> 4117 bytes .../__pycache__/career_goal.cpython-312.pyc | Bin 0 -> 1838 bytes .../__pycache__/focus_log.cpython-312.pyc | Bin 0 -> 1811 bytes .../__pycache__/pomodoro.cpython-312.pyc | Bin 0 -> 5785 bytes .../__pycache__/suggestion.cpython-312.pyc | Bin 0 -> 1999 bytes .../models/__pycache__/usage.cpython-312.pyc | Bin 0 -> 2837 bytes .../models/__pycache__/user.cpython-312.pyc | Bin 0 -> 2597 bytes backend/app/models/calendar.py | 73 +++ backend/app/models/career_goal.py | 29 + backend/app/models/focus_log.py | 29 + backend/app/models/pomodoro.py | 119 ++++ backend/app/models/suggestion.py | 31 + backend/app/models/usage.py | 51 ++ backend/app/models/user.py | 45 ++ backend/app/routers/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 186 bytes .../routers/__pycache__/auth.cpython-312.pyc | Bin 0 -> 5551 bytes .../__pycache__/calendar.cpython-312.pyc | Bin 0 -> 14295 bytes .../__pycache__/pomodoro.cpython-312.pyc | Bin 0 -> 27331 bytes .../__pycache__/suggestions.cpython-312.pyc | Bin 0 -> 8101 bytes .../routers/__pycache__/usage.cpython-312.pyc | Bin 0 -> 9173 bytes backend/app/routers/auth.py | 115 ++++ backend/app/routers/calendar.py | 295 +++++++++ backend/app/routers/pomodoro.py | 560 ++++++++++++++++++ backend/app/routers/suggestions.py | 173 ++++++ backend/app/routers/usage.py | 230 +++++++ backend/app/schemas/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 186 bytes .../__pycache__/calendar.cpython-312.pyc | Bin 0 -> 2956 bytes .../__pycache__/career_goal.cpython-312.pyc | Bin 0 -> 1244 bytes .../__pycache__/pomodoro.cpython-312.pyc | Bin 0 -> 3128 bytes .../__pycache__/suggestion.cpython-312.pyc | Bin 0 -> 2167 bytes .../schemas/__pycache__/usage.cpython-312.pyc | Bin 0 -> 1435 bytes .../schemas/__pycache__/user.cpython-312.pyc | Bin 0 -> 1251 bytes backend/app/schemas/calendar.py | 70 +++ backend/app/schemas/career_goal.py | 23 + backend/app/schemas/pomodoro.py | 83 +++ backend/app/schemas/suggestion.py | 51 ++ backend/app/schemas/usage.py | 26 + backend/app/schemas/user.py | 25 + backend/app/services/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 187 bytes .../services/__pycache__/auth.cpython-312.pyc | Bin 0 -> 4216 bytes .../__pycache__/ws_manager.cpython-312.pyc | Bin 0 -> 5939 bytes backend/app/services/auth.py | 69 +++ backend/app/services/classroom_sync.py | 97 +++ backend/app/services/gmail_parser.py | 174 ++++++ backend/app/services/llm.py | 37 ++ backend/app/services/suggestion_engine.py | 138 +++++ backend/app/services/ws_manager.py | 88 +++ backend/main.py | 5 + backend/requirements.txt | 17 + 67 files changed, 2846 insertions(+) create mode 100644 backend/.env.example create mode 100644 backend/.gitignore create mode 100644 backend/__pycache__/main.cpython-312.pyc create mode 100644 backend/app/__init__.py create mode 100644 backend/app/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/app/__pycache__/config.cpython-312.pyc create mode 100644 backend/app/__pycache__/database.cpython-312.pyc create mode 100644 backend/app/__pycache__/logging_config.cpython-312.pyc create mode 100644 backend/app/__pycache__/main.cpython-312.pyc create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/logging_config.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/app/models/__pycache__/calendar.cpython-312.pyc create mode 100644 backend/app/models/__pycache__/career_goal.cpython-312.pyc create mode 100644 backend/app/models/__pycache__/focus_log.cpython-312.pyc create mode 100644 backend/app/models/__pycache__/pomodoro.cpython-312.pyc create mode 100644 backend/app/models/__pycache__/suggestion.cpython-312.pyc create mode 100644 backend/app/models/__pycache__/usage.cpython-312.pyc create mode 100644 backend/app/models/__pycache__/user.cpython-312.pyc create mode 100644 backend/app/models/calendar.py create mode 100644 backend/app/models/career_goal.py create mode 100644 backend/app/models/focus_log.py create mode 100644 backend/app/models/pomodoro.py create mode 100644 backend/app/models/suggestion.py create mode 100644 backend/app/models/usage.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/app/routers/__pycache__/auth.cpython-312.pyc create mode 100644 backend/app/routers/__pycache__/calendar.cpython-312.pyc create mode 100644 backend/app/routers/__pycache__/pomodoro.cpython-312.pyc create mode 100644 backend/app/routers/__pycache__/suggestions.cpython-312.pyc create mode 100644 backend/app/routers/__pycache__/usage.cpython-312.pyc create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/calendar.py create mode 100644 backend/app/routers/pomodoro.py create mode 100644 backend/app/routers/suggestions.py create mode 100644 backend/app/routers/usage.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/app/schemas/__pycache__/calendar.cpython-312.pyc create mode 100644 backend/app/schemas/__pycache__/career_goal.cpython-312.pyc create mode 100644 backend/app/schemas/__pycache__/pomodoro.cpython-312.pyc create mode 100644 backend/app/schemas/__pycache__/suggestion.cpython-312.pyc create mode 100644 backend/app/schemas/__pycache__/usage.cpython-312.pyc create mode 100644 backend/app/schemas/__pycache__/user.cpython-312.pyc create mode 100644 backend/app/schemas/calendar.py create mode 100644 backend/app/schemas/career_goal.py create mode 100644 backend/app/schemas/pomodoro.py create mode 100644 backend/app/schemas/suggestion.py create mode 100644 backend/app/schemas/usage.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/app/services/__pycache__/auth.cpython-312.pyc create mode 100644 backend/app/services/__pycache__/ws_manager.cpython-312.pyc create mode 100644 backend/app/services/auth.py create mode 100644 backend/app/services/classroom_sync.py create mode 100644 backend/app/services/gmail_parser.py create mode 100644 backend/app/services/llm.py create mode 100644 backend/app/services/suggestion_engine.py create mode 100644 backend/app/services/ws_manager.py create mode 100644 backend/requirements.txt diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..377491e --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,18 @@ +# ── Google OAuth ── +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret +GOOGLE_REDIRECT_URI=http://localhost:8000/auth/callback + +# ── JWT ── +JWT_SECRET_KEY=change-me-to-a-random-secret +JWT_ALGORITHM=HS256 +JWT_EXPIRE_MINUTES=1440 + +# ── Database ── +DATABASE_URL=postgresql+asyncpg://focusforge:focusforge@localhost:5432/focusforge + +# ── LLM ── +GEMINI_API_KEY=your-gemini-api-key + +# ── App ── +APP_ENV=development diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..0e47061 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,4 @@ +.env +venv/ +venv +.venv/ \ No newline at end of file diff --git a/backend/__pycache__/main.cpython-312.pyc b/backend/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..271b2d1a0992fcb8c9a176e5263ed3d6273f991e GIT binary patch literal 443 zcmXv~JxIeq6uxV&zqWO9(4`q1>|hhbL5fI=(o%|5XcrX;F?Tev?OnJe#dgw3M>kh- zlRCJ#ySs>pXj}v*w?em0UbKA=?tS0A_rCXdPpMP_2%Cd9)`N)O0hwB40!9r1t^fuY zw!uCi5Qkbz0Psu*kPwyz@V^cM!NKvgYpNn5MmUTkIC>WwplNLn_Fy*t6VRF;)3lCm zQBCWw9+a5jIVHxMN{z%jeaz>Sg+R@4oT4 P_=d{EFhC1DDcXJk_~>~m literal 0 HcmV?d00001 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/__pycache__/__init__.cpython-312.pyc b/backend/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..93af878113049150298de4385d20cc831f467bfe GIT binary patch literal 178 zcmX@j%ge<81ZKY$W`gL)AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxd6{w$)pPQ;*QCX0Y zpIWT%l3JWyl3$=7P?VpQnp{$>pORXZQ(B;#k(ivFSdx*Sr|;<(?CFBJV1Qbin yOVLj(DA13O&&55r~UHjE~HWjEqIhKo$T4LoO%) literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/config.cpython-312.pyc b/backend/app/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c4f64ce4d24eedb22743893b17b836ebd97e841e GIT binary patch literal 1339 zcmY*Z&2Jk;6rcUDW5*x)up6kFrfG|i401MVX%(Uh0S%N&t&6Y#X%CFHyW`z$);rtT z*-}Rwj3`niEhKIvY$c3sq@*@=A0LLT(PpP|rVQQFAk1gLR8 zj5AKsovj2?@fk@UMsGXmAa)ZUlI&48OM8_0Wb;IQ|0MNm?_9m|zaUZd9PZqQ3PZw? zsQU!yL+m7>N~z+JMikq`_#+en`2SM_+jQmxrddYHH49JCgw19;+S%Whcr$xW@T_OIR~sF zMNNu22w{XVXLAzHOR*?zRp2KPc4^!T{a?`-Z0Pu&70^Dh9uJa$lGNHF={Bc{^(mtd zpi-#?kBqXU9Y7r&9?;mj-Ra)D+qwP0O-t66#GVCDw{p_8WWb8@J}Kl7rYVT;(TnAk zeX}`o56qRr)l2*4>wA|E%u9zCw)V}}_8JG~)=?3idw19w-8?Wi4$rO*?~bk>n5%~? z5WBQzKr95D#oF-B2o8QFqtEBj%b+(e4sSi}Li~%>H-G!`kH$ye-eKpbS%@OEX~yDH z^9cV7BxiEG1Y#F)4K}r=Q{IK)?zFW0G0Wh}30FW9RiDV#%x9im1d|L%Wfg|#uS(;G z&3*HXitn_FVj&rLPRv91-Erwg-WKn%H^3`1O|OGE xGBizlj^28XF1#u>wU4!@-@HO{f4PSA%6I3EbWK|t1^*)0#s<=BFQq}H^&huna>M`t literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/database.cpython-312.pyc b/backend/app/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0dd17284cb0ff3379b5ab6bc266635d141164881 GIT binary patch literal 1774 zcmY*ZO>Ep$5T0jy{j+hhS%);;D3z8ZsHBK26-ec$6lLj;D%t?*rL}}Ccm1;2#9nWo zoj-o0sI5??BGDpfdPLk%TG0b{lw7%V6{KBEEAexny=_UPmWl)O?A;`NlIP8vH#2X> z^UeI+-JJ$p{p7pKNfF>Le&{T@&1^nEW(6E@hzlm)1($e&DR`nOdXgy-E)(69C!2E2 zORnOnrb<8n3NuE>;m%Iet0Xy@U8tk3wy#VbPXg6v26uEj97=~e{Lb#AW?uwwWJkHB zu1bmA%%B&MW~k(7x6-Sa7hh(t04Kodx(tp^88?@;PhOUOk;_RWgc?n$uTkN zD=}(^)Ut!6YRRJ2a-~WmBRK^$2r7Qnvu7!b^a-wUCJ~L1D07sST$|ZpWu6x8fTBn* z)6fcN7*?v~AnJ1HJazq=N2}q(=kU3QybAcFL`&0tq}6=iwSvmWG}1kL(emdhn{xey z<6}Z3qvs&7qF%aKt1xQ$RjcHCUL`E?usf^$b750Q$qE+1Bo3GYrYA%a4~gfeT2|He zsAWZI%kq3@&gHyrSr_JPH@W#9Ho42E9~P#4j}{h}YSTUq3P))$8~U}v2h5+Lr7$R9 zKJHxYd|2Su;JB~14dtosdNMf^OKg3myM zPvN$7LtG&QxtTU5(A)OvSYJXk$F@>p8zUNJtN1KmmxO116Ig;n@Wre64U9n8#vs5b zuvZ9#oi2hIEwSHC9n$H8II6DQcM1PYROtIg9MvI6VeS`U0T#t`ut4zbVd)GeAh*|=6MCjw>iW>#eaC;%PoQzb7_J+`8^&luAFb=7O+DLEQ1NL? zx&frH?aoM zp5TIOyQOLBEsfK~a6Fzu6`ye&GY&b%J1FYerRFn_aqvZHY`yW4U!AIy8SkG+!ykSs zE)(OkneF33b$dGLoi?@Rm>Vf*jl*v~l9LI@_-IbLmlurj_D!0W7xSrlIGH3z*h_fi zRSuSsv_wM4Z_x8MoUOyz`*7esjQp+*UKwa;gLQ52=Ia~U=(7B4*TB^y%jzH6P*c(y zQm!uLnp(D@A}*jk}QDQVG$n Tyt4(kwN#ZD*W4|@EspSi38<|l literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/logging_config.cpython-312.pyc b/backend/app/__pycache__/logging_config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aa5d715daf893461454142712a10a6874e332096 GIT binary patch literal 1948 zcmcgs&2Jk;6yLRH*RGS;Y1({18#0wJu_MK#TRp z%xtKwAmxCBKuGNY2@V|iI6#z0QKd>15)yv^7mSM>mYgUDxCJFhs3&IZ-Pnf;4pcnJ z@4b0%=J#fv_x-*b7zhbWhQ-hI6(9%?`N9*)bq=0q&PReK@g^LK@RMb^uYKh-@?Tmey>1a$LD%C3}PT)F>#tgKE3{zq2>GPdPdqHNRv4U-? z8J^(_LpPCW#WncL#)s@!1reeYS*m_K`r34~G9A@m^wpW@@=WxqBeg*t86#?AQx$o2 zR|3rX+3(;lAMXM^%2BrN$huJWdgX5)u#mk5BBVNOgGI)PyWB$RQjizKA{@&|4<|FBx=-xY?j{*K_+wV z@FGY{2x7~k?YTx6zNOO=RQPlgolrLSe6%YHg^G$TLZD(8uwrSpfk>hQ#UqxfBFHyY ztB_CyQ@aXdx(N-vfJjv_A)|+iqMM0oE#5h{5|=Qa6i4phI{{6hs8FGfOejW5#gSM- zMfI?~rmGe<6N;*`*{>fbZyJiBmQbaZKqjLiWyxWwg1cD0JI0~Rw2S*P!&LUgs$4kxsfP3IS~SC&&NPK5jOsg+A!z)xy~-%Yb@ z>KtRJONp=Mh<@RyBDUTV_pWXQ4^?T|2OLv!=CsP~C zzkId^!pg1 ze1v$X)C3bP5Nm)K+dr|%052Q?-YGW0Gc7RL0F&E+oycb70XW}HmYd*A3rsb@)b@)z zvzxO&f|m~1TZOoYIhn=-P9UGxEH$6UoM!P!t~hn#6HGaN_PMpYv?<_m9zDg>qu|BP zNB%NC!;g1 F_cxyTnEL<# literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..649095a3f9725e82defd3aa1b8e8a7b5bb3a9092 GIT binary patch literal 3705 zcmbVO-ES1v6~A|O_WR4;^%rXs);2b*KX!rIfi!L+wQumpEqm$`T^+x$1&}oo|01ffHa^FN`v~4G{os~ZCFoB zY3@sWwdR79Az`J3L}vx1^#id^Jp}$nl{Tea=^%%0?^zXVJ{~5A=SjnR2a#qaQNT(k z9|7I&T6I^~Fan8!!`FB#d!2nNuY7>&c1M7Fp~2-K5}4N<+;}@0xQ+iO<$Eb9&r3;( zFC`_sSZc|W+V?NE;S{F6lA#-lVH$wlWlJs)070uF~DOFK4vM8HOZ>T`*oJDL~Ef%bl zKs}^bP>Y0$$7PGHhheQ;%4tRc$ORyX=Pa%@FaZjP`IaGTAQqme`E%j|DcUsrj;a$| zKCh7(&9GrTI9@89V_SLKD9{*8CS;ow40CBpE|sc^eJ724*@E?yNTX+KA($bS1zV=! z8ki#bd16uzbK<9Gsr$IB@bEOx)WdU1wQNvKU7$H%D>|A{4RWx{U2wsv~p>-xeE5X?q;Wd1zM8{}lI&t*q88V`K*%+4+!z z8gHj`M$i?kpr5%Ax*^>y`PSD=-03$CgK z-d2F-_XhRm+Wv^l;u-cDrdUf1ICy%u~83rpxI z`sBCpEW)GqqQi*q5!&Gm9)qLEjV!zfhs?W>IT(GibkX%LTExIT`h{x|r~K12sE`Xp zGfFxu0eAMq@pErcadP_P87LdK#@3dTGq!B9T1)b5KWUf+l7!k!9wfyJKkBnm%RN~c z3I~cW$?L~TULdAAs}d!dN#;!gWuq*2&CHc~#U>SFvG|;-<&L_BYAq)f)hZblQBoo5 zb4s0hp*(V6)QmtNsfQIf6qcwKXAP5OA9bH6Q1aAgmWw&oSIjV@`dD2n;35rFy~)X% zM!hu?DVN#Fxcs0l$9GIc=to5hvK1wh$!AlUIYTFzi%X?BgIJj?u@-EjlsRh}^CWLu z8Hk`(E)CDg`32dYGm4qX>6tTA)05*9ne#w{mX`s)GrFu6)1@Wq)6`jFmE@v13HSmx%H(~q`Ii5l*mGYDe0ci3(`!vf{&=b~ zl3gFkZi*+Kcu-T@wM$noef;xp5q7`2!-mgVwj!}>`c?hY>~E%iJyq!%S??Ozh>TWz zqiep=Z(YE`nIAeDM>fULhoZkePj8Bu`+=rw>8t6DK%ych*2F~J{J=eNaHkn`Jl*n* zKHXv3o^o}3c*Godwi7|_&Sw@oTHhZ}X8ZA%Jvi(4eA(+_xPLesb=`^MY|wM3MP#@m z2=qI>I2#u4>>m%nR7FAVu)EanVTh3vCvb9D;2rZ5|_W;q;?h;xQgpUx3k z0d33P3?R*(%Y)DLA4|V0@H}QcuiE+*tO$zhQ(*@2@)f74U@R<0X4xSGdn}M)xumiK z(XwT`Y*8BGa7Cz5sF&3`v>>S9>QzBg&znBR=ADI`0&Lr4#~5v|&C+`P!jR4z;3~VD z(*ScG6wImxlbu^M#0ac2u;#^ENVQ|c7`71(~Pqk_mT%8|V-T9MM&2-g0#kOwvEGCcx(CMF=m*)m@gSjfM~L8|Ti$;ocs+JGghxHd8`%mZwwg|Dh2mS`)+c_q zS6FdD-Z~ z^({LL?F8y-uD@Q1_pQhKHc|f;a#dAdf3p%#t;bWFXrNA8i}zLHL+kOOO*C95ULUK( U``6?Bo9IA;RujI526%w~2hpmNZ2$lO literal 0 HcmV?d00001 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..e3076a6 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,30 @@ +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + # Google OAuth + google_client_id: str = "" + google_client_secret: str = "" + google_redirect_uri: str = "http://localhost:8000/auth/callback" + + # JWT + jwt_secret_key: str = "change-me-to-a-random-secret" + jwt_algorithm: str = "HS256" + jwt_expire_minutes: int = 1440 # 24 hours + + # Database + database_url: str = "postgresql+asyncpg://focusforge:focusforge@localhost:5432/focusforge" + + # LLM + gemini_api_key: str = "" + + # App + app_env: str = "development" + + model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} + + +@lru_cache() +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..6e36d3d --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,33 @@ +import logging + +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy.orm import DeclarativeBase +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + +engine = create_async_engine( + settings.database_url, + echo=settings.app_env == "development", + pool_size=20, + max_overflow=10, +) + +AsyncSessionLocal = async_sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def get_db() -> AsyncSession: + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + logger.exception("Database session error — rolling back") + await session.rollback() + raise diff --git a/backend/app/logging_config.py b/backend/app/logging_config.py new file mode 100644 index 0000000..acecfea --- /dev/null +++ b/backend/app/logging_config.py @@ -0,0 +1,33 @@ +"""Centralized logging configuration for FocusForge backend.""" + +import logging +import sys + + +LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d — %(message)s" +LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" + + +def setup_logging(level: str = "INFO") -> None: + """ + Configure the root logger with a consistent format across all modules. + Call once at app startup (in lifespan or main.py). + """ + numeric_level = getattr(logging, level.upper(), logging.INFO) + + logging.basicConfig( + level=numeric_level, + format=LOG_FORMAT, + datefmt=LOG_DATE_FORMAT, + stream=sys.stdout, + force=True, # override any prior basicConfig + ) + + # Quieten noisy third-party loggers + logging.getLogger("uvicorn.access").setLevel(logging.WARNING) + logging.getLogger("sqlalchemy.engine").setLevel( + logging.INFO if numeric_level <= logging.DEBUG else logging.WARNING + ) + logging.getLogger("google").setLevel(logging.WARNING) + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..7170728 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,75 @@ +from app.routers import pomodoro as pomodoro_router +from app.routers import usage as usage_router +from app.routers import suggestions as suggestions_router +from app.routers import calendar as calendar_router +from app.routers import auth as auth_router +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config import get_settings +from app.database import engine, Base +from app.logging_config import setup_logging + +logger = logging.getLogger(__name__) + +# Import all models so they're registered with Base.metadata +from app.models.user import User # noqa: F401 +from app.models.career_goal import CareerGoal # noqa: F401 +from app.models.calendar import CalendarEvent, TimetableSlot # noqa: F401 +from app.models.usage import AppUsageLog, AppCategoryMapping # noqa: F401 +from app.models.focus_log import FocusLog # noqa: F401 +from app.models.pomodoro import PomodoroSession, SessionMember, UserXP, Badge # noqa: F401 +from app.models.suggestion import SuggestionHistory # noqa: F401 + + +settings = get_settings() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + log_level = "DEBUG" if settings.app_env == "development" else "INFO" + setup_logging(level=log_level) + logger.info("Starting FocusForge API (env=%s)", settings.app_env) + + # Create tables (dev convenience; use Alembic in production) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + logger.info("Database tables verified / created") + + yield + + logger.info("Shutting down FocusForge API") + await engine.dispose() + logger.info("Database engine disposed") + + +app = FastAPI( + title="FocusForge API", + description="Backend for FocusForge — Attention Economy on Campus", + version="1.0.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/health") +async def health_check(): + return {"status": "ok", "service": "focusforge-api"} + + +# ── Routers ── +app.include_router(auth_router.router) +app.include_router(calendar_router.router) +app.include_router(suggestions_router.router) +app.include_router(usage_router.router) +app.include_router(pomodoro_router.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/__pycache__/__init__.cpython-312.pyc b/backend/app/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..596ca1ba70e1f83a134255d340dd61c1fd5ed6bf GIT binary patch literal 185 zcmX@j%ge<81h&5xW`gL)AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxd6``MzpPQ;*QCX0Y zpIWT%l3JWyl3$=7P?VpQnp{$>pORXZQ(B;#k(ivFSdx*Sr|;<(?CFBJV1Qbin zOVLj(DA3Q%Pf5)w){l?R%*!l^kJl@x{Ka7d6fDh2wJTx;TEqy%#UREPx# literal 0 HcmV?d00001 diff --git a/backend/app/models/__pycache__/calendar.cpython-312.pyc b/backend/app/models/__pycache__/calendar.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fb1fe3412aec7bb4cc7c7a84f486803fc46dc4da GIT binary patch literal 4117 zcmd57(25#A-2tQogzt<$f9LSv6Z5tTVs1zthrAX1MFko_Mu3(&nZ7w~4-%wmsUct+v&Z}^$Pw=UJ!LJ5{fXWNJ+9WioK_RGygpe8*!WNEGB5G8Ks?9>Pwe~43 zYOBzywh3)&yU=dw{7Ovi5IR`K!(3zJz>gT2Cmq{f`#eIYrELOjuuj`mWD}tm2$@fK zOiEaWl?kICJ-HJdVha|<8OUx7IOn^HbcWHX{#qnOc@f|@hKlqiys)pGh$HlJWk z?uw+7jD3Dv25O7LAH%gCF@zCVnGrm<;E@Hd>=8H*LwxYhFMD^mZPrH1UJ_Vtsdr<~ zcYN;SG?5@+!mnbwK}ABc|9u!==NoikIUw^pP1_#ZTJ}Qh;5KKkgvgsb%b``cP0fF} z&VN*n9G`qA>i89!!Ba~O-`s$2aq!M4wANu;9V}^sleM}2+7Z)migp(n1LSe!4nTI+ zBF9`zSA(|`faz}V*5M-2+i~2J=&b?S=^)`Xqq2LO2CGxAoVM#tR$v!&Gu!iu-H8Uf z`nNtlWA7_g-HsLMYdBXwAma_UIq4v++njQ8BKPd{x^F*#{%bGVSBD=2-~EmS*bgmV zx4)x$xm{cA#9e0+UfmA5=*C{MGhM2q4* zEG{xxlnRPrdb09MzMvDTk7Z>uFg>*}J(ao&mb{jei9!qlm50w26h*qD5Yv|h?frQ( zfF@#AesKZLy~WZ&7|fs*S2T)wl0AaDVfu`$p%B;}!0nJs^bF1Bu{b$ct3o<J%yr23<+S8xU6fr7cunDLmi~r3Xu~mJ%x%^Rg(#|e2BUs z83i5hh2?lv%fYeCC|qI%?%_N}FhdCxV_B4pgonOkIn{E7ynRZ0v^LL}yeQ@*m58Dl z6h(+rP;ed=#ZL;7QriiLqO4^=9T7zXpU>9QR&Y$C=$*sdm_C#jX%DKg#G1{bDCKe* z92x7lC{o}@h7O_dqZmbyS2rjkQmq>y1T@YK$AxP%s?c|cClH~njen}&GGDY-zhL%r$ zhic0tW9UhltSE*2`6VfHPcoLYTyl1P;p3b0vs2T_JD`I+Cm~@;%#oreoo{z6U!)xv z3I$$U2g+bRV-C29Kg34XZvL?+alnU5>3#m>CbuhpwfuDX$m{9#uU%R9uNyxpJ}g%F z?nB1o^Z(V)BrZGH$8%3spUv*~%>1skqntkSc@zG%YwO=Dr5??0%s$~NvEfQ&Jw>7hSVSDbGyx%vyHdpeO^o>xJn?C3s{bFS^^Roxl?uoUl>z7MI58tbD69=Oc zyQ#g4`=i&_J}PyVy=D1vu+lnI<*py}pW947i|zMMT3(EeaFv^^gQIJsEN?uhv{>-r zw|9s4V*A50YuDEoOX+fI;|G=KK$V+07#e@t`ON#}sXb$V=!UhsP`Hw*h`-`Oodh{TPxo2AR;;U}@jZ&lhy?GU3A7=nHtEYCrJ(KBCj9i^3W zX5#@mKLf_YZy)e2<>7t4f0y6ozT%(qUj`vJ!+z`X_bV-NCoiulw_uboUCkG`>wsj*JTVI2~6_^Y8~aXTE+>h8Azi?rA5Bd-1N zeynak>g)qcb;!+*4`&oQ0ozsc)$)2QaBHi}nAop$$!&E#nA`n8gl@pb9N(}$xqKP2 z_)kBVhdW%a*lqOIPT_PleR7xV**W22`ipIeZZm>SvsGCa6wO$@^j(rEP;9c}pAvFU zDaNtZTl;Yx0D2sllTaYjJFvAx;z(k^n=VSUP-)r)hopD{yC(PItPe#$iV+l8jDgXl zqNpv37|jd<#ZW^;XEuxB?8V;^Y*pzqFq^l_??qJNV_2mX8hbcL0_X_qqL|n61?b?3 zo`}=;5QB-J-$pTs;sX>eh0qUiIfbGJ1@Z^Ig5sDnuqCHAK$zZa&aheGCh9C!n8%q# zE7mcmacPl+%L4Q&>W*2U4VFM%aA-P%JJug3_E>bz5tm@12jQ<0EB6QgedQiZzEZh= zEkj`#tSj4Y}Yj%~)|EVPKgykmJyH||mbTUM80aXj=ouJnQQtEFEj@I-116q^Yv;Y7A literal 0 HcmV?d00001 diff --git a/backend/app/models/__pycache__/career_goal.cpython-312.pyc b/backend/app/models/__pycache__/career_goal.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d043564176d37c249860d0f768689803d6b3f14d GIT binary patch literal 1838 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 literal 0 HcmV?d00001 diff --git a/backend/app/models/calendar.py b/backend/app/models/calendar.py new file mode 100644 index 0000000..8fa22da --- /dev/null +++ b/backend/app/models/calendar.py @@ -0,0 +1,73 @@ +import uuid +from datetime import datetime, date, time + +from sqlalchemy import String, Text, DateTime, Date, Time, ForeignKey, Integer, func +from sqlalchemy.dialects.postgresql import UUID, JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class CalendarEvent(Base): + """Unified calendar events from all sources.""" + + __tablename__ = "calendar_events" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), + nullable=True, index=True, + ) + # "assignment" | "institute_holiday" | "fest" | "club_event" | "gmail_event" + event_type: Mapped[str] = mapped_column(String(50), index=True) + title: Mapped[str] = mapped_column(String(500)) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + event_date: Mapped[date] = mapped_column(Date, index=True) + event_time: Mapped[time | None] = mapped_column(Time, nullable=True) + end_date: Mapped[date | None] = mapped_column(Date, nullable=True) + location: Mapped[str | None] = mapped_column(String(500), nullable=True) + + # Source reference (e.g. Classroom course ID, Gmail message ID) + source_id: Mapped[str | None] = mapped_column( + String(500), nullable=True, index=True) + # "classroom" | "gmail" | "admin" | "manual" + source: Mapped[str | None] = mapped_column(String(50), nullable=True) + + # Extra metadata as JSONB + metadata_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + + # Admin moderation status: "pending" | "approved" | "rejected" + moderation_status: Mapped[str] = mapped_column( + String(20), default="approved") + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + +class TimetableSlot(Base): + """Recurring weekly timetable slots entered by the user.""" + + __tablename__ = "timetable_slots" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + day_of_week: Mapped[int] = mapped_column(Integer) # 0=Monday .. 6=Sunday + start_time: Mapped[time] = mapped_column(Time) + end_time: Mapped[time] = mapped_column(Time) + title: Mapped[str] = mapped_column(String(255)) + location: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + user = relationship("User", back_populates="timetable_slots") diff --git a/backend/app/models/career_goal.py b/backend/app/models/career_goal.py new file mode 100644 index 0000000..5325ee6 --- /dev/null +++ b/backend/app/models/career_goal.py @@ -0,0 +1,29 @@ +import uuid +from datetime import datetime + +from sqlalchemy import String, Text, DateTime, ForeignKey, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class CareerGoal(Base): + __tablename__ = "career_goals" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + title: Mapped[str] = mapped_column(String(255)) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + user = relationship("User", back_populates="career_goals") diff --git a/backend/app/models/focus_log.py b/backend/app/models/focus_log.py new file mode 100644 index 0000000..1c01567 --- /dev/null +++ b/backend/app/models/focus_log.py @@ -0,0 +1,29 @@ +import uuid +from datetime import datetime, date + +from sqlalchemy import String, Integer, Date, DateTime, ForeignKey, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class FocusLog(Base): + """Daily focus minutes, used for the 90-day heatmap.""" + + __tablename__ = "focus_logs" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + log_date: Mapped[date] = mapped_column(Date, index=True) + focus_minutes: Mapped[int] = mapped_column(Integer, default=0) + mode: Mapped[str] = mapped_column(String(20)) # "acads" | "clubs" + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + user = relationship("User", back_populates="focus_logs") diff --git a/backend/app/models/pomodoro.py b/backend/app/models/pomodoro.py new file mode 100644 index 0000000..c8b0155 --- /dev/null +++ b/backend/app/models/pomodoro.py @@ -0,0 +1,119 @@ +import uuid +from datetime import datetime + +from sqlalchemy import ( + String, Integer, Float, Boolean, DateTime, ForeignKey, Text, func, +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class PomodoroSession(Base): + """A group Pomodoro session (2-8 members).""" + + __tablename__ = "pomodoro_sessions" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + invite_code: Mapped[str] = mapped_column(String(20), unique=True, index=True) + created_by: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE") + ) + # Session configuration + focus_duration_min: Mapped[int] = mapped_column(Integer, default=25) + break_duration_min: Mapped[int] = mapped_column(Integer, default=5) + total_intervals: Mapped[int] = mapped_column(Integer, default=4) + + # State: "waiting" | "focus" | "break" | "completed" + status: Mapped[str] = mapped_column(String(20), default="waiting") + current_interval: Mapped[int] = mapped_column(Integer, default=0) + + started_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + ended_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + members = relationship( + "SessionMember", back_populates="session", cascade="all, delete-orphan" + ) + + +class SessionMember(Base): + """A participant in a Pomodoro session.""" + + __tablename__ = "session_members" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + session_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("pomodoro_sessions.id", ondelete="CASCADE"), + index=True, + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + is_paused: Mapped[bool] = mapped_column(Boolean, default=False) + focus_seconds: Mapped[int] = mapped_column(Integer, default=0) + xp_earned: Mapped[int] = mapped_column(Integer, default=0) + last_heartbeat: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + joined_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + session = relationship("PomodoroSession", back_populates="members") + + +class UserXP(Base): + """Lifetime and weekly XP tracking per user per group.""" + + __tablename__ = "user_xp" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + session_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("pomodoro_sessions.id", ondelete="CASCADE"), + index=True, + ) + xp: Mapped[int] = mapped_column(Integer, default=0) + awarded_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + user = relationship("User", back_populates="user_xp") + + +class Badge(Base): + """Badge definitions and awards.""" + + __tablename__ = "badges" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + badge_type: Mapped[str] = mapped_column( + String(50) + ) # "streak_7", "streak_30", "top_focus" + awarded_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) diff --git a/backend/app/models/suggestion.py b/backend/app/models/suggestion.py new file mode 100644 index 0000000..367c900 --- /dev/null +++ b/backend/app/models/suggestion.py @@ -0,0 +1,31 @@ +import uuid +from datetime import datetime + +from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, func +from sqlalchemy.dialects.postgresql import UUID, JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class SuggestionHistory(Base): + """Log of past suggestions shown to the user.""" + + __tablename__ = "suggestion_history" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + mode: Mapped[str] = mapped_column(String(20)) # "acads" | "clubs" + suggestions_json: Mapped[dict] = mapped_column(JSONB) + quote: Mapped[str | None] = mapped_column(Text, nullable=True) + is_completed: Mapped[bool] = mapped_column(Boolean, default=False) + is_dismissed: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + user = relationship("User", back_populates="suggestion_history") diff --git a/backend/app/models/usage.py b/backend/app/models/usage.py new file mode 100644 index 0000000..1770cfe --- /dev/null +++ b/backend/app/models/usage.py @@ -0,0 +1,51 @@ +import uuid +from datetime import datetime, date + +from sqlalchemy import String, Integer, Date, DateTime, ForeignKey, BigInteger, func, Index +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class AppUsageLog(Base): + """Per-app usage snapshots posted by the client every 30 minutes.""" + + __tablename__ = "app_usage_logs" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), + ) + package_name: Mapped[str] = mapped_column(String(500)) + category: Mapped[str | None] = mapped_column( + String(50), nullable=True + ) # "productive" | "neutral" | "distraction" + duration_ms: Mapped[int] = mapped_column(BigInteger) + log_date: Mapped[date] = mapped_column(Date, index=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + user = relationship("User", back_populates="app_usage_logs") + + __table_args__ = ( + Index("ix_usage_user_date", "user_id", "log_date"), + ) + + +class AppCategoryMapping(Base): + """Config table mapping package names to usage categories.""" + + __tablename__ = "app_category_mappings" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + package_name: Mapped[str] = mapped_column(String(500), unique=True, index=True) + category: Mapped[str] = mapped_column( + String(50) + ) # "productive" | "neutral" | "distraction" + display_name: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..d17a763 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,45 @@ +import uuid +from datetime import datetime + +from sqlalchemy import String, Text, DateTime, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + google_id: Mapped[str] = mapped_column(String(255), unique=True, index=True) + email: Mapped[str] = mapped_column(String(320), unique=True, index=True) + display_name: Mapped[str] = mapped_column(String(255)) + avatar_url: Mapped[str | None] = mapped_column(Text, nullable=True) + focus_mode: Mapped[str] = mapped_column( + String(20), default="acads" + ) # "acads" | "clubs" + + # Google tokens for Classroom / Gmail API + google_access_token: Mapped[str | None] = mapped_column(Text, nullable=True) + google_refresh_token: Mapped[str | None] = mapped_column(Text, nullable=True) + + # Role: "student" | "admin" (club secretary / coordinator) + role: Mapped[str] = mapped_column(String(20), default="student") + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + # Relationships + career_goals = relationship("CareerGoal", back_populates="user", cascade="all, delete-orphan") + timetable_slots = relationship("TimetableSlot", back_populates="user", cascade="all, delete-orphan") + app_usage_logs = relationship("AppUsageLog", back_populates="user", cascade="all, delete-orphan") + focus_logs = relationship("FocusLog", back_populates="user", cascade="all, delete-orphan") + user_xp = relationship("UserXP", back_populates="user", cascade="all, delete-orphan") + suggestion_history = relationship("SuggestionHistory", back_populates="user", cascade="all, delete-orphan") diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/__pycache__/__init__.cpython-312.pyc b/backend/app/routers/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e546a3119e501d6ced9adbe6ebd126743e309474 GIT binary patch literal 186 zcmX@j%ge<81W*1f%mmSoK?FMZ%mNgd&QQsq$>_I|p@<2{`wUX^D^forKQ~psqOu?( zKebrjCAB!aB)>pEpeR2pHMyi%KP9y+r?fyfBQZHUu_PluPv6ro*x%RB)6rQ!2`HAD zm!h9oP@rFwUs{q{RIDE#pP83g5+AQuQ2C3)CO1E&G$+-rh!toPBM=vZ7$2D#85xV1 Gfh+((7&0RO literal 0 HcmV?d00001 diff --git a/backend/app/routers/__pycache__/auth.cpython-312.pyc b/backend/app/routers/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..205ee8be7231f51cf88df31fed8d9c740e93a336 GIT binary patch literal 5551 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 literal 0 HcmV?d00001 diff --git a/backend/app/routers/__pycache__/calendar.cpython-312.pyc b/backend/app/routers/__pycache__/calendar.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8c852f2fbf5c82ee2fe47c05164911356a600314 GIT binary patch literal 14295 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 T$!sWbcKas=s7Zec!sovMNNxuW literal 0 HcmV?d00001 diff --git a/backend/app/routers/__pycache__/pomodoro.cpython-312.pyc b/backend/app/routers/__pycache__/pomodoro.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..26802044100517c0a04d1db9d0ef790f12265f1d GIT binary patch literal 27331 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 OGtB1}An|!n)c*lEeENm} literal 0 HcmV?d00001 diff --git a/backend/app/routers/__pycache__/usage.cpython-312.pyc b/backend/app/routers/__pycache__/usage.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..24e1bfa2a33d7129aa458bbd060923a66f928185 GIT binary patch literal 9173 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 iQDTH&Z= start_date) + if end_date: + filters.append(CalendarEvent.event_date <= end_date) + if event_type: + filters.append(CalendarEvent.event_type == event_type) + + result = await db.execute(select(CalendarEvent).where(and_(*filters))) + return [CalendarEventOut.model_validate(e) for e in result.scalars().all()] + + +@router.post("/calendar/events", response_model=CalendarEventOut, status_code=201) +async def create_calendar_event( + body: CalendarEventCreate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + event = CalendarEvent(user_id=user.id, **body.model_dump()) + db.add(event) + await db.flush() + return CalendarEventOut.model_validate(event) + + +# ═══════════════════════════════════════════════════════════════ +# ADMIN: INSTITUTE CALENDAR CSV UPLOAD (FR-16) +# ═══════════════════════════════════════════════════════════════ + + +@router.post("/admin/institute-calendar", status_code=201) +async def upload_institute_calendar( + file: UploadFile = File(...), + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + Admin uploads CSV with columns: date, title, type (holiday/fest), description (optional). + Inserts as global events (user_id=NULL). + """ + if not file.filename.endswith(".csv"): + raise HTTPException(status_code=400, detail="Only CSV files accepted") + + content = await file.read() + reader = csv.DictReader(io.StringIO(content.decode("utf-8"))) + logger.info("Admin %s uploading institute calendar CSV: %s", + admin.id, file.filename) + + inserted = 0 + for row in reader: + try: + event_date = datetime.strptime( + row["date"].strip(), "%Y-%m-%d").date() + except (KeyError, ValueError): + continue # skip malformed rows + + event_type = row.get("type", "institute_holiday").strip() + if event_type not in ("institute_holiday", "fest"): + event_type = "institute_holiday" + + event = CalendarEvent( + user_id=None, + event_type=event_type, + title=row.get("title", "").strip(), + description=row.get("description", "").strip() or None, + event_date=event_date, + source="admin", + moderation_status="approved", + ) + db.add(event) + inserted += 1 + + logger.info( + "Institute calendar upload: %d events inserted by admin %s", inserted, admin.id) + return {"inserted": inserted} + + +# ═══════════════════════════════════════════════════════════════ +# ADMIN EVENT PANEL (FR-25, FR-26) +# Club secretaries / coordinators post events manually +# ═══════════════════════════════════════════════════════════════ + + +@router.post("/admin/events", response_model=CalendarEventOut, status_code=201) +async def admin_create_event( + body: AdminEventCreate, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """Club secretary posts a club event — starts as 'pending' moderation.""" + event = CalendarEvent( + user_id=None, + event_type="club_event", + title=body.title, + description=body.description, + event_date=body.event_date, + event_time=body.event_time, + end_date=body.end_date, + location=body.location, + source="admin", + moderation_status="pending", + ) + db.add(event) + await db.flush() + logger.info( + "Admin %s created club event '%s' (pending moderation)", admin.id, body.title) + return CalendarEventOut.model_validate(event) + + +@router.get("/admin/events", response_model=list[CalendarEventOut]) +async def admin_list_events( + moderation_status: str | None = Query(None), + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + filters = [CalendarEvent.source == "admin"] + if moderation_status: + filters.append(CalendarEvent.moderation_status == moderation_status) + result = await db.execute(select(CalendarEvent).where(and_(*filters))) + return [CalendarEventOut.model_validate(e) for e in result.scalars().all()] + + +@router.patch("/admin/events/{event_id}/approve", response_model=CalendarEventOut) +async def admin_approve_event( + event_id: uuid.UUID, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id)) + event = result.scalar_one_or_none() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + event.moderation_status = "approved" + await db.flush() + logger.info("Event %s approved by admin %s", event_id, admin.id) + return CalendarEventOut.model_validate(event) + + +@router.delete("/admin/events/{event_id}", status_code=204) +async def admin_delete_event( + event_id: uuid.UUID, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id)) + event = result.scalar_one_or_none() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + await db.delete(event) + + +# ═══════════════════════════════════════════════════════════════ +# GOOGLE CLASSROOM SYNC (FR-06) +# ═══════════════════════════════════════════════════════════════ + + +@router.post("/calendar/sync/classroom") +async def sync_classroom( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Pull assignment deadlines from all enrolled Google Classroom courses. + Upserts into calendar_events with type='assignment'. + """ + if not user.google_access_token: + raise HTTPException( + status_code=400, + detail="Google tokens not available. Re-authenticate with Classroom scope.", + ) + + from app.services.classroom_sync import sync_classroom_assignments + + count = await sync_classroom_assignments(user, db) + logger.info( + "Classroom sync for user %s: %d assignments synced", user.id, count) + return {"synced_assignments": count} + + +# ═══════════════════════════════════════════════════════════════ +# GMAIL CLUB EVENT PARSING (FR-07) +# ═══════════════════════════════════════════════════════════════ + + +@router.post("/calendar/sync/gmail") +async def sync_gmail_events( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Parse Gmail for club activity emails, extract events via LLM. + Upserts into calendar_events with type='gmail_event'. + """ + if not user.google_access_token: + raise HTTPException( + status_code=400, + detail="Google tokens not available. Re-authenticate with Gmail scope.", + ) + + from app.services.gmail_parser import parse_gmail_club_events + + count = await parse_gmail_club_events(user, db) + logger.info("Gmail sync for user %s: %d events parsed", user.id, count) + return {"parsed_events": count} diff --git a/backend/app/routers/pomodoro.py b/backend/app/routers/pomodoro.py new file mode 100644 index 0000000..6a060ac --- /dev/null +++ b/backend/app/routers/pomodoro.py @@ -0,0 +1,560 @@ +import uuid +import logging +import secrets +from datetime import datetime, timezone, timedelta, date + +from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, Query +from sqlalchemy import select, and_, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db, AsyncSessionLocal +from app.models.user import User +from app.models.pomodoro import PomodoroSession, SessionMember, UserXP, Badge +from app.models.focus_log import FocusLog +from app.schemas.pomodoro import ( + PomodoroSessionCreate, + PomodoroSessionOut, + SessionJoin, + SessionMemberOut, + LeaderboardResponse, + LeaderboardEntry, + BadgeOut, +) +from app.services.auth import get_current_user, decode_access_token +from app.services.ws_manager import manager + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["pomodoro"]) + +XP_PER_INTERVAL = 100 # XP awarded per completed focus interval + + +# ═══════════════════════════════════════════════════════════════ +# SESSION CRUD (FR-11) +# ═══════════════════════════════════════════════════════════════ + + +@router.post("/pomodoro/sessions", response_model=PomodoroSessionOut, status_code=201) +async def create_session( + body: PomodoroSessionCreate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + invite_code = secrets.token_urlsafe(6)[:8].upper() + session = PomodoroSession( + invite_code=invite_code, + created_by=user.id, + focus_duration_min=body.focus_duration_min, + break_duration_min=body.break_duration_min, + total_intervals=body.total_intervals, + ) + db.add(session) + await db.flush() + + # Creator auto-joins + member = SessionMember(session_id=session.id, user_id=user.id) + db.add(member) + await db.flush() + + logger.info("Pomodoro session created: id=%s, invite=%s, by user %s", + session.id, invite_code, user.id) + return PomodoroSessionOut.model_validate(session) + + +@router.post("/pomodoro/sessions/join", response_model=PomodoroSessionOut) +async def join_session( + body: SessionJoin, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(PomodoroSession).where( + PomodoroSession.invite_code == body.invite_code) + ) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + if session.status not in ("waiting",): + raise HTTPException( + status_code=400, detail="Session already started or completed") + + # Check member count (2-8) + member_count = await db.execute( + select(func.count()).where(SessionMember.session_id == session.id) + ) + if member_count.scalar() >= 8: + raise HTTPException( + status_code=400, detail="Session is full (max 8 members)") + + # Check if already joined + existing = await db.execute( + select(SessionMember).where( + and_( + SessionMember.session_id == session.id, + SessionMember.user_id == user.id, + ) + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=400, detail="Already joined this session") + + member = SessionMember(session_id=session.id, user_id=user.id) + db.add(member) + await db.flush() + + # Refresh session with members + await db.refresh(session) + logger.info("User %s joined pomodoro session %s via invite %s", + user.id, session.id, body.invite_code) + return PomodoroSessionOut.model_validate(session) + + +@router.get("/pomodoro/sessions/{session_id}", response_model=PomodoroSessionOut) +async def get_session( + session_id: uuid.UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(PomodoroSession).where(PomodoroSession.id == session_id) + ) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + return PomodoroSessionOut.model_validate(session) + + +# ═══════════════════════════════════════════════════════════════ +# LEADERBOARD (FR-21) +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/pomodoro/sessions/{session_id}/leaderboard", response_model=LeaderboardResponse) +async def get_leaderboard( + session_id: uuid.UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Weekly XP leaderboard within a group.""" + week_start = date.today() - timedelta(days=date.today().weekday()) + + result = await db.execute( + select( + UserXP.user_id, + func.sum(UserXP.xp).label("total_xp"), + ) + .where( + and_( + UserXP.session_id == session_id, + UserXP.awarded_at >= datetime.combine( + week_start, datetime.min.time()), + ) + ) + .group_by(UserXP.user_id) + .order_by(func.sum(UserXP.xp).desc()) + ) + rows = result.all() + + entries = [] + for rank, row in enumerate(rows, 1): + # Fetch display name + user_result = await db.execute(select(User).where(User.id == row.user_id)) + u = user_result.scalar_one_or_none() + entries.append( + LeaderboardEntry( + user_id=row.user_id, + display_name=u.display_name if u else "Unknown", + total_xp=int(row.total_xp), + rank=rank, + ) + ) + + return LeaderboardResponse(session_id=session_id, entries=entries) + + +# ═══════════════════════════════════════════════════════════════ +# BADGES (FR-23) +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/pomodoro/badges/{user_id}", response_model=list[BadgeOut]) +async def get_badges( + user_id: uuid.UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(Badge).where(Badge.user_id == user_id).order_by( + Badge.awarded_at.desc()) + ) + return [BadgeOut.model_validate(b) for b in result.scalars().all()] + + +# ═══════════════════════════════════════════════════════════════ +# WEBSOCKET (FR-12, FR-13, FR-22) +# Real-time timer sync at /ws/pomodoro/{session_id} +# ═══════════════════════════════════════════════════════════════ + + +@router.websocket("/ws/pomodoro/{session_id}") +async def pomodoro_websocket( + websocket: WebSocket, + session_id: uuid.UUID, + token: str = Query(...), +): + """ + WebSocket endpoint for Pomodoro session sync. + Client sends: {"type": "heartbeat"} or {"type": "pause"} or {"type": "resume"} or {"type": "start"} + Server sends: tick events every second, state changes, nudges. + """ + # Authenticate via token query param + try: + user_id = decode_access_token(token) + except Exception: + logger.warning( + "WS auth failed for session %s — invalid token", session_id) + await websocket.close(code=4001, reason="Invalid token") + return + + session_id_str = str(session_id) + await manager.connect(session_id_str, user_id, websocket) + + try: + # Notify others of new connection + await manager.broadcast(session_id_str, { + "type": "member_update", + "data": { + "user_id": user_id, + "action": "connected", + "member_count": manager.get_member_count(session_id_str), + }, + }) + + while True: + data = await websocket.receive_json() + msg_type = data.get("type") + + if msg_type == "heartbeat": + manager.record_heartbeat(session_id_str, user_id) + + elif msg_type == "start": + # Only session creator can start + await _handle_start(session_id, user_id, session_id_str) + + elif msg_type == "pause": + # Voluntary pause — local only, no disclosure (FR-13) + async with AsyncSessionLocal() as db: + result = await db.execute( + select(SessionMember).where( + and_( + SessionMember.session_id == session_id, + SessionMember.user_id == uuid.UUID(user_id), + ) + ) + ) + member = result.scalar_one_or_none() + if member: + member.is_paused = True + await db.commit() + + elif msg_type == "resume": + async with AsyncSessionLocal() as db: + result = await db.execute( + select(SessionMember).where( + and_( + SessionMember.session_id == session_id, + SessionMember.user_id == uuid.UUID(user_id), + ) + ) + ) + member = result.scalar_one_or_none() + if member: + member.is_paused = False + await db.commit() + manager.record_heartbeat(session_id_str, user_id) + + except WebSocketDisconnect: + manager.disconnect(session_id_str, user_id) + await manager.broadcast(session_id_str, { + "type": "member_update", + "data": { + "user_id": user_id, + "action": "disconnected", + "member_count": manager.get_member_count(session_id_str), + }, + }) + + +async def _handle_start(session_id: uuid.UUID, user_id: str, session_id_str: str): + """Start the Pomodoro session and begin the timer loop.""" + import asyncio + + async with AsyncSessionLocal() as db: + result = await db.execute( + select(PomodoroSession).where(PomodoroSession.id == session_id) + ) + session = result.scalar_one_or_none() + if not session or str(session.created_by) != user_id: + return + if session.status != "waiting": + return + + session.status = "focus" + session.current_interval = 1 + session.started_at = datetime.now(timezone.utc) + await db.commit() + + logger.info("Pomodoro session %s started by user %s", session_id, user_id) + + await manager.broadcast(session_id_str, { + "type": "state_change", + "data": {"status": "focus", "interval": 1}, + }) + + # Run the timer loop in background + asyncio.create_task(_timer_loop(session_id, session_id_str)) + + +async def _timer_loop(session_id: uuid.UUID, session_id_str: str): + """Server-side timer loop — broadcasts ticks and manages intervals.""" + import asyncio + + async with AsyncSessionLocal() as db: + result = await db.execute( + select(PomodoroSession).where(PomodoroSession.id == session_id) + ) + session = result.scalar_one_or_none() + if not session: + return + + focus_secs = session.focus_duration_min * 60 + break_secs = session.break_duration_min * 60 + total_intervals = session.total_intervals + + for interval in range(1, total_intervals + 1): + # ── Focus phase ── + async with AsyncSessionLocal() as db: + result = await db.execute( + select(PomodoroSession).where(PomodoroSession.id == session_id) + ) + s = result.scalar_one_or_none() + if s: + s.status = "focus" + s.current_interval = interval + await db.commit() + + await manager.broadcast(session_id_str, { + "type": "state_change", + "data": {"status": "focus", "interval": interval}, + }) + + for second in range(focus_secs, 0, -1): + if manager.get_member_count(session_id_str) == 0: + logger.warning( + "Timer loop aborted — no members in session %s", session_id_str) + return # No one connected, stop + + await manager.broadcast(session_id_str, { + "type": "tick", + "data": { + "status": "focus", + "interval": interval, + "remaining_seconds": second, + }, + }) + + # Check for stale members every 15 seconds (accountability nudge FR-22) + if second % 15 == 0: + stale = manager.get_stale_members(session_id_str) + for stale_uid in stale: + logger.info( + "Nudge sent to user %s in session %s (stale heartbeat)", stale_uid, session_id_str) + await manager.send_to_user(session_id_str, stale_uid, { + "type": "nudge", + "data": {"message": "You seem to have lost focus. Stay strong!"}, + }) + # Mark as low-focus internally (invisible to others) + async with AsyncSessionLocal() as db: + member_result = await db.execute( + select(SessionMember).where( + and_( + SessionMember.session_id == session_id, + SessionMember.user_id == uuid.UUID( + stale_uid), + ) + ) + ) + member = member_result.scalar_one_or_none() + if member: + member.is_paused = True + await db.commit() + + await asyncio.sleep(1) + + # Award XP for completed focus interval (server-verified, FR-20) + async with AsyncSessionLocal() as db: + members_result = await db.execute( + select(SessionMember).where( + and_( + SessionMember.session_id == session_id, + SessionMember.is_active == True, + ) + ) + ) + for member in members_result.scalars().all(): + if not member.is_paused: + member.focus_seconds += focus_secs + member.xp_earned += XP_PER_INTERVAL + xp_record = UserXP( + user_id=member.user_id, + session_id=session_id, + xp=XP_PER_INTERVAL, + ) + db.add(xp_record) + + # Update focus log for heatmap + today = date.today() + focus_log_result = await db.execute( + select(FocusLog).where( + and_( + FocusLog.user_id == member.user_id, + FocusLog.log_date == today, + ) + ) + ) + focus_log = focus_log_result.scalar_one_or_none() + if focus_log: + focus_log.focus_minutes += focus_secs // 60 + else: + db.add(FocusLog( + user_id=member.user_id, + log_date=today, + focus_minutes=focus_secs // 60, + mode="acads", # could be dynamic + )) + await db.commit() + + # ── Break phase (skip after last interval) ── + if interval < total_intervals: + async with AsyncSessionLocal() as db: + result = await db.execute( + select(PomodoroSession).where( + PomodoroSession.id == session_id) + ) + s = result.scalar_one_or_none() + if s: + s.status = "break" + await db.commit() + + await manager.broadcast(session_id_str, { + "type": "state_change", + "data": {"status": "break", "interval": interval}, + }) + + for second in range(break_secs, 0, -1): + if manager.get_member_count(session_id_str) == 0: + return + await manager.broadcast(session_id_str, { + "type": "tick", + "data": { + "status": "break", + "interval": interval, + "remaining_seconds": second, + }, + }) + await asyncio.sleep(1) + + # ── Session complete ── + async with AsyncSessionLocal() as db: + result = await db.execute( + select(PomodoroSession).where(PomodoroSession.id == session_id) + ) + session = result.scalar_one_or_none() + if session: + session.status = "completed" + session.ended_at = datetime.now(timezone.utc) + await db.commit() + logger.info("Pomodoro session %s completed", session_id) + + # Evaluate badges (FR-23) + await _evaluate_badges(session_id, db) + + # Broadcast final results + async with AsyncSessionLocal() as db: + members_result = await db.execute( + select(SessionMember).where(SessionMember.session_id == session_id) + ) + final_members = [ + { + "user_id": str(m.user_id), + "xp_earned": m.xp_earned, + "focus_seconds": m.focus_seconds, + } + for m in members_result.scalars().all() + ] + + await manager.broadcast(session_id_str, { + "type": "session_end", + "data": {"members": final_members}, + }) + + +async def _evaluate_badges(session_id: uuid.UUID, db: AsyncSession): + """Check and award badges: streaks (7-day, 30-day) and top-focus-member.""" + members_result = await db.execute( + select(SessionMember).where(SessionMember.session_id == session_id) + ) + members = members_result.scalars().all() + if not members: + return + + # Top focus member badge + top = max(members, key=lambda m: m.focus_seconds) + existing = await db.execute( + select(Badge).where( + and_( + Badge.user_id == top.user_id, + Badge.badge_type == "top_focus", + ) + ) + ) + if not existing.scalar_one_or_none(): + db.add(Badge(user_id=top.user_id, badge_type="top_focus")) + + # Streak badges — check focus_logs for consecutive days + for member in members: + today = date.today() + for streak_days, badge_type in [(7, "streak_7"), (30, "streak_30")]: + # Check if they have focus logs for the last N consecutive days + consecutive = 0 + for d in range(streak_days): + check_date = today - timedelta(days=d) + log_result = await db.execute( + select(FocusLog).where( + and_( + FocusLog.user_id == member.user_id, + FocusLog.log_date == check_date, + FocusLog.focus_minutes > 0, + ) + ) + ) + if log_result.scalar_one_or_none(): + consecutive += 1 + else: + break + + if consecutive >= streak_days: + existing_badge = await db.execute( + select(Badge).where( + and_( + Badge.user_id == member.user_id, + Badge.badge_type == badge_type, + ) + ) + ) + if not existing_badge.scalar_one_or_none(): + db.add(Badge(user_id=member.user_id, badge_type=badge_type)) + + await db.commit() diff --git a/backend/app/routers/suggestions.py b/backend/app/routers/suggestions.py new file mode 100644 index 0000000..55dc1d2 --- /dev/null +++ b/backend/app/routers/suggestions.py @@ -0,0 +1,173 @@ +import logging +import uuid +from datetime import date, timedelta + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select, and_, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.user import User +from app.models.career_goal import CareerGoal +from app.models.focus_log import FocusLog +from app.schemas.career_goal import CareerGoalCreate, CareerGoalUpdate, CareerGoalOut +from app.schemas.suggestion import ( + SuggestionResponse, + HeatmapResponse, + HeatmapEntry, + SuggestionHistoryOut, + SuggestionStatusUpdate, +) +from app.services.auth import get_current_user + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["suggestions"]) + + +# ═══════════════════════════════════════════════════════════════ +# CAREER GOALS (FR-15) +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/user/{user_id}/goals", response_model=list[CareerGoalOut]) +async def get_career_goals( + user_id: uuid.UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(CareerGoal).where(CareerGoal.user_id == user_id) + ) + return [CareerGoalOut.model_validate(g) for g in result.scalars().all()] + + +@router.put("/user/{user_id}/goals", response_model=list[CareerGoalOut]) +async def set_career_goals( + user_id: uuid.UUID, + goals: list[CareerGoalCreate], + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Replace all career goals for a user.""" + # Delete existing + existing = await db.execute( + select(CareerGoal).where(CareerGoal.user_id == user_id) + ) + for g in existing.scalars().all(): + await db.delete(g) + + # Insert new + new_goals = [] + for goal_data in goals: + goal = CareerGoal(user_id=user_id, **goal_data.model_dump()) + db.add(goal) + new_goals.append(goal) + + await db.flush() + return [CareerGoalOut.model_validate(g) for g in new_goals] + + +# ═══════════════════════════════════════════════════════════════ +# SUGGESTIONS (FR-03, FR-04) +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/suggestions/{user_id}", response_model=SuggestionResponse) +async def get_suggestions( + user_id: uuid.UUID, + mode: str = Query("acads", regex="^(acads|clubs)$"), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + LLM-scored suggestions based on focus mode, career goals, + upcoming 48h calendar events, and workload score. + """ + from app.services.suggestion_engine import generate_suggestions + + logger.info("Fetching suggestions for user %s, mode=%s", user_id, mode) + result = await generate_suggestions(user_id, mode, db) + return result + + +# ═══════════════════════════════════════════════════════════════ +# SUGGESTION HISTORY (FR-24) +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/suggestions/{user_id}/history", response_model=list[SuggestionHistoryOut]) +async def get_suggestion_history( + user_id: uuid.UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + from app.models.suggestion import SuggestionHistory + + result = await db.execute( + select(SuggestionHistory) + .where(SuggestionHistory.user_id == user_id) + .order_by(SuggestionHistory.created_at.desc()) + .limit(50) + ) + return [SuggestionHistoryOut.model_validate(s) for s in result.scalars().all()] + + +@router.patch("/suggestions/history/{suggestion_id}", response_model=SuggestionHistoryOut) +async def update_suggestion_status( + suggestion_id: uuid.UUID, + body: SuggestionStatusUpdate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + from app.models.suggestion import SuggestionHistory + + result = await db.execute( + select(SuggestionHistory).where(SuggestionHistory.id == suggestion_id) + ) + suggestion = result.scalar_one_or_none() + if not suggestion: + raise HTTPException(status_code=404, detail="Suggestion not found") + + if body.is_completed is not None: + suggestion.is_completed = body.is_completed + if body.is_dismissed is not None: + suggestion.is_dismissed = body.is_dismissed + + await db.flush() + return SuggestionHistoryOut.model_validate(suggestion) + + +# ═══════════════════════════════════════════════════════════════ +# FOCUS HEATMAP (FR-14) +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/focus-log/{user_id}/heatmap", response_model=HeatmapResponse) +async def get_focus_heatmap( + user_id: uuid.UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """90-day GitHub-style focus heatmap — daily Pomodoro + active-session minutes.""" + start = date.today() - timedelta(days=90) + + result = await db.execute( + select(FocusLog).where( + and_( + FocusLog.user_id == user_id, + FocusLog.log_date >= start, + ) + ) + ) + logs = result.scalars().all() + + entries = [ + HeatmapEntry( + date=log.log_date.isoformat(), + focus_minutes=log.focus_minutes, + mode=log.mode, + ) + for log in logs + ] + + return HeatmapResponse(user_id=user_id, entries=entries) diff --git a/backend/app/routers/usage.py b/backend/app/routers/usage.py new file mode 100644 index 0000000..5a721a2 --- /dev/null +++ b/backend/app/routers/usage.py @@ -0,0 +1,230 @@ +import logging +import uuid +from datetime import date, timedelta + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select, and_, func, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.user import User +from app.models.usage import AppUsageLog, AppCategoryMapping +from app.schemas.usage import AppUsageBatchCreate, RollingAverageResponse, AppUsageStats +from app.services.auth import get_current_user + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["usage"]) + + +# ═══════════════════════════════════════════════════════════════ +# BATCH POST USAGE DATA (FR-09) +# ═══════════════════════════════════════════════════════════════ + + +@router.post("/usage", status_code=201) +async def post_usage_data( + body: AppUsageBatchCreate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Client POSTs [{package_name, duration_ms, date}] every 30 minutes. + Backend maps package names to categories via config table. + """ + # Pre-load category mappings + mappings_result = await db.execute(select(AppCategoryMapping)) + category_map = { + m.package_name: m.category for m in mappings_result.scalars().all()} + + inserted = 0 + for entry in body.entries: + category = category_map.get(entry.package_name, "neutral") + + log = AppUsageLog( + user_id=user.id, + package_name=entry.package_name, + category=category, + duration_ms=entry.duration_ms, + log_date=entry.date, + ) + db.add(log) + inserted += 1 + + logger.info("Usage data ingested: %d entries for user %s", + inserted, user.id) + return {"inserted": inserted} + + +# ═══════════════════════════════════════════════════════════════ +# ROLLING AVERAGES & PERCENTILE (FR-18, FR-10) +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/usage/{user_id}/rolling-average", response_model=RollingAverageResponse) +async def get_rolling_average( + user_id: uuid.UUID, + period_days: int = 7, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Returns user's rolling average per category vs institute average, + plus percentile rank via PERCENT_RANK() window function. + Uses anonymized aggregation — raw package names never exposed (NFR-01). + """ + start_date = date.today() - timedelta(days=period_days) + + # User's average per category + user_avg_query = ( + select( + AppUsageLog.category, + func.avg(AppUsageLog.duration_ms).label("user_avg_ms"), + ) + .where( + and_( + AppUsageLog.user_id == user_id, + AppUsageLog.log_date >= start_date, + AppUsageLog.category.isnot(None), + ) + ) + .group_by(AppUsageLog.category) + ) + user_avgs = await db.execute(user_avg_query) + user_data = {row.category: float(row.user_avg_ms) for row in user_avgs} + + # Institute average per category (all opted-in users) + institute_avg_query = ( + select( + AppUsageLog.category, + func.avg(AppUsageLog.duration_ms).label("inst_avg_ms"), + ) + .where( + and_( + AppUsageLog.log_date >= start_date, + AppUsageLog.category.isnot(None), + ) + ) + .group_by(AppUsageLog.category) + ) + inst_avgs = await db.execute(institute_avg_query) + inst_data = {row.category: float(row.inst_avg_ms) for row in inst_avgs} + + # Percentile rank for the user's productive usage + # Uses PERCENT_RANK() window function over all users + percentile_query = text(""" + WITH user_totals AS ( + SELECT + user_id, + category, + AVG(duration_ms) AS avg_ms + FROM app_usage_logs + WHERE log_date >= :start_date AND category IS NOT NULL + GROUP BY user_id, category + ), + ranked AS ( + SELECT + user_id, + category, + avg_ms, + PERCENT_RANK() OVER (PARTITION BY category ORDER BY avg_ms) AS pct_rank + FROM user_totals + ) + SELECT category, pct_rank + FROM ranked + WHERE user_id = :user_id + """) + percentile_result = await db.execute( + percentile_query, {"start_date": start_date, "user_id": user_id} + ) + percentiles = {row.category: float(row.pct_rank) + for row in percentile_result} + + # Assemble response + all_categories = set(user_data.keys()) | set(inst_data.keys()) + stats = [] + for cat in sorted(all_categories): + stats.append( + AppUsageStats( + category=cat, + user_avg_ms=user_data.get(cat, 0.0), + institute_avg_ms=inst_data.get(cat, 0.0), + user_percentile=percentiles.get(cat), + ) + ) + + return RollingAverageResponse( + user_id=user_id, + period_days=period_days, + stats=stats, + ) + + +# ═══════════════════════════════════════════════════════════════ +# NUDGE CHECK (FR-10) +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/usage/{user_id}/should-nudge") +async def check_nudge( + user_id: uuid.UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Returns whether the user's productive usage is below the 50th percentile + of institute peers. The client uses this to fire a local push notification. + """ + start_date = date.today() - timedelta(days=7) + + percentile_query = text(""" + WITH user_totals AS ( + SELECT + user_id, + AVG(duration_ms) AS avg_ms + FROM app_usage_logs + WHERE log_date >= :start_date AND category = 'productive' + GROUP BY user_id + ), + ranked AS ( + SELECT + user_id, + avg_ms, + PERCENT_RANK() OVER (ORDER BY avg_ms) AS pct_rank + FROM user_totals + ) + SELECT pct_rank + FROM ranked + WHERE user_id = :user_id + """) + result = await db.execute( + percentile_query, {"start_date": start_date, "user_id": user_id} + ) + row = result.first() + + if row is None: + return {"should_nudge": False, "percentile": None} + + should_nudge = row.pct_rank < 0.5 + return {"should_nudge": should_nudge, "percentile": round(row.pct_rank * 100, 1)} + + +# ═══════════════════════════════════════════════════════════════ +# APP CATEGORY MAPPINGS (NFR-09 — DB config, not hardcoded) +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/usage/categories") +async def list_categories( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """List all known app-to-category mappings.""" + result = await db.execute(select(AppCategoryMapping)) + return [ + { + "package_name": m.package_name, + "category": m.category, + "display_name": m.display_name, + } + for m in result.scalars().all() + ] diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/__pycache__/__init__.cpython-312.pyc b/backend/app/schemas/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2734e1de4c6ad8981a1f9a18f5e46a558dd0a833 GIT binary patch literal 186 zcmX@j%ge<81WSJ{%mmSoK?FMZ%mNgd&QQsq$>_I|p@<2{`wUX^D^forKQ~psqOu?( zKebrjCAB!aB)>pEpeR2pHMyi%KP9y+r?fyfBQZHUu_PluPv6ro*x%RB)6rQ!2`HAD zm!h9oP@rF&oRONFSgao(pP83g5+AQuQ2C3)CO1E&G$+-rh!toPBM=vZ7$2D#85xV1 Gfh+(CfHATF literal 0 HcmV?d00001 diff --git a/backend/app/schemas/__pycache__/calendar.cpython-312.pyc b/backend/app/schemas/__pycache__/calendar.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8635a1b2a24a8076156a4a6511f05fdd3f87e4a3 GIT binary patch literal 2956 zcmcIm&u`pB6!y=x_h&XuC~ev_Zb%B2+OCASQGrTI+XS+$)bvnaEXOl#oOtbJ>9#;+su1xKpj3rS z8fG%ja)2qA5!KRYj;QxFn5mE)Vr|vbl8F^~aqO)6^p+pS3yk8vio|K#;a&StokWge#>$X=Ej)uLJik+03mdfdNxQig(x`TYMjLV1tlebcI(6fy zM(C#BYF=1#+zlsQ3xnEX{nm};`r_q<+A3hM*P7F8)*^R}Hk_#DIzB)SyVz_q9oFX| z4@q=E4%AYl^OQR;K9TB*@N;ye%A3- zBR{J4Ig^j5rab0gUdkg#G3AkBrgCm|$T*Kgv+`6Y8s}CeIn&-?HNYN9mVp}sy zN%a8RH(GHbc_h(V5oNYV82%Xy+l&=4lpxeOBT2#K${@A_?A>Ky1Cp3|tF4$uD`tsR zaQ+yEDuhI7wP0Q4_U*+h>=>fs7?5Q)fdN@&*tJOsbV7aG4THPhJvNCr|BH`fc483l z@n{hOu%wqB=6Cbk%lqZ=hj(`Gbd}@(N?cc&*{`1HDkpxOoc}WZVdDI!AEs}?7aUIH zw@^7qprn=}}-b%ElI9k-h_oW(K*yrLKaXrel?eIxDGGqYPCbS66Sqp7Yk+wWz-mO2VxMZ``U z(9fi^vb)h$j7)cJ|q0s;6G#--i0f=4&j0Dqq6*8U1sae~-$ z8GPubpDF24h)llMKGTT=Fh^JUP%>= qJ+mjk(5n{23!QdPfT3p;#B+$l(3`5HI1Ig6O*A^~zXTY#@BIUtcbHB9 literal 0 HcmV?d00001 diff --git a/backend/app/schemas/__pycache__/career_goal.cpython-312.pyc b/backend/app/schemas/__pycache__/career_goal.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a83c24fa5dc30a0a6c472e8f1584327806de397 GIT binary patch literal 1244 zcmZ{kO>5LZ7{_OlY&QGWR$HY?+e5{}LTf-lM2euTlvTSGbPo+eNHf!J+H4Xh6LEX0 zRKcFQo_f`z_znC9;zg*3B~lPPc@tVM#e>hxZgIQR9RBmv8oaF6V!WiPbVr4dQUhy#|FicgKn8LP)6ZcGm4^HwikJg7;lSk%bAe zlp3Nc)d-#emnar&hbS9aguqjU*Hp?{IyCDrO4)qq__K^csx-qr?$c^oAT)NF7jZ8P z%6e+pcHlIqZKrwLZiFQ9MO?J)r-|ctd(@b-bkMe)APBi5CdRhS49N8!9e|>vCuTit zQ1f{+s)sZ-=V-jn!^nKd!Zqsh*d+9spG4Dj$6a@LJq*lB_0i%&wQ_sbTn3K@#B`#_ zjNLkIII-!<&D*Q6`zukC>5xK-2;y@fuTZ-%uywCJF}ZoaHQeHFE`HS}+2HZG(=lF$ z?p}-pTN0#%mm!J-iY;Bh?Rp)c_$1I;B<`-tfH{&B%2}2Zv8Xjx5F)#ZA}R?HsMvrI zxy2z7ojn#YA)Ww%LyeAY-hF+oHL`tkPa8Y!bYj-&r`v#@-qTTaO{DF6Xr@y*|Lv6& zs1D=g*@G-Y~`9-IhU6HmE2;&Q^g}`Hi;>-JtBEjC5ANb z%6cTW!_&bP7B(Q@%v(-4jUSiueI!#YNnjzZR;$c)SW2oAsQ5n8BGfGPZ8r>7yw!fj zBQSyq5yaygAi&rt?&P*}TMO;dnVqHWr9JI@e=Y54lkK6=J#F;+$mO^E%kb2T2c3%( zOTlGdfQHHJM${xuz&)2~!jpw6FIHwgdE-aMOy~Q$#l~SU{Ekq3737zSG5&@M`)GV0 cO&sb9zSL?SB4`fty>S*#w<^C8G;%Y40iQGze*gdg literal 0 HcmV?d00001 diff --git a/backend/app/schemas/__pycache__/pomodoro.cpython-312.pyc b/backend/app/schemas/__pycache__/pomodoro.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8043b6a1a8640b2666ac39959c78fb7d16c9e556 GIT binary patch literal 3128 zcmb7G&2Jk;6yNoZ?OlJ{aneL>(vYU5t_YS80t5v_KU5MLr_ffYzO2@sNwST-Yi8D= z<`k45wWp@1<_Jf`pTZ@TbFdawLgJJgQq83j67S7AiDNk}U1{IU{NBvYy!U?Z&HkQD z#uRw;e}AzjBZ~4D4i27huk&ILI!_g{CM>}$19T`Oy<5>jp`lx!(9 z%(UOMU~QR5Gy-U}4-HFH2h`|8BNB}P8t+4+5={V_>_c^lrT|U%p@u{=fM)y9m_&1c zj`X4NmRiW~<1tGG)z_)zG0(0u=o?oo!4_T0YSqAsK=w>hUVH@OrwUWbDpks%yWp0x z#s~}3&}Gn2tsIdfB1hqJRE{DzYDEjWKYGipyVT|GZ6<{6I#)S^z4s?T@8_1?YEziB z$t@35X5Du7F${jL0<+eRD+nsmp6gjP({?<@*R7f;!4A4nwI0hS8zvRnj9Iy6Ci$7UVv0y-Yc%Ubyj@PYOK0U6t6L{=DCgH zE$-fDRZkQtTdy@6bE{T$&GJ@Vr#N4_y|h@G|Ke(~0vK~>(P}h`qPoiJmMAuQ_P^U` zap0UHg#jHg1L7y8Gc~4T28i%Le|d>aA&FM`5~M7aobt9J!V#cwSKHB8AZ)X zONRL2$3fFyEf~~_fO)M@LG$B1r!Kg*!*On@ z5FT(Hp9NU@Y|sN2QGsF_UV$VB4`lOuBp;j#Rl`rU1)AfhdS#BBltHrMN$v+Zg1K~Z zj_9D3IYM=60H=*stPtmC;VAqR2tV2sjGMp`!xpAh_3U*fyA2Bz)K3P*Q81tb6~6JH zVKR$5pfT=)_atHYTkxSfr9y1bhge$pITVt7q~f}@fPIp97f>_6FL^K&Z^J7-2GP&2 zWcKmBNB7!ftdkzuTnfalOeOP%zoHaRuO68zZkcr1YC4dQWBv#=Ho)TdUs5b-lN znME0|xE80^9gny8CD_7GP+K%=R!d$Gz8+j24;nt0s zz$-AJ!B4yaJOKj6SM3$?R@!=2`OG9yu2nj8lgj zybPK*F!=wb!!!a3r^_D@{fb3b7}Rf>bpbD86~s)h)c`M9-veomrIYa*7s(2B zVj_J4)^QZq;T6ck;fa*NM3UupFmZ74aL-|0ANSlrd1Ybf9TCF#RxCpFtrg}IX(oYR zKC4uS44QUWZGA%Rsy7()&tXH3;%j&X{w9GjvE=4_Czje;2m+1jLW0YhgI@9VMvGdG zXID9aVer=@uP<4_u`C1xlZ=w?lG_|lvX}bh;AieLehpTr@CpaS-0vhcv@QcA5j;VtN;K2 literal 0 HcmV?d00001 diff --git a/backend/app/schemas/__pycache__/suggestion.cpython-312.pyc b/backend/app/schemas/__pycache__/suggestion.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e37fb9155623445d82319a9106ba1da26d1ac4f2 GIT binary patch literal 2167 zcmah~&u<$=6yEjD+UwtT;)Wzj+ejoxR#OZDA%QByk5<&sIEWCWn8Rx0nIxO+dfk~_ zz&#ZuNaR#=YL0LN@dxlnaG`Pz)>f5}IB`RYxl}^py;(bUEF(tRH{X6Uvor5|Z{Gfz z&88G+#y>yUzi5i`2fhrOJ^<%G0(h)Yg{qEH$3Et$u3A@B#F`UxwYsJ%G35h=l4FJH zO#2zWg{@8`8wWNq#_EzafK86Eaml8DO^>k&$!37fj++>sjF0}$F>xvF+$Z_z)BR8NC!^4;0U6& zUXF*k&0uej3E%dbRiC+`?%Te@!Zc-~!R;3Eq0!>D$8EnI#)TK~2CKnB!!(9DUdEEdM9v96fpdd1c>oS>>yCYu{s{ za+`@m-)mJq=H3Bo_@YAD7f#T+xoDSOh_CXgqFS*kSwB+4$UIEGBh|Su7Uv(QrV+wuXv@?d{SKPlGR9*(!v>8 zh*sjF=~6s^k;_sX7(h)lrVmmu4k!{P0Z0l%N!?&^N**LYHl6A+Et7!a7bqc73OEB1 zP12OD*3)fLPKTwDkaxjxkGD4hKaAOw&%xBNFs7z?AiQRc&m*6)h1u}jmcx8TYJ_ll}g>|aNufu+P8B94bp;a{W5{_f8%1M(dlyLyfi|~kRU_gcCm528_ zE2pLJZgk1Y*xMOdiG0Kb(|ufnk;@s1-e4XvEQ1r*6mm)rGj}2WuGPBJ^m!X130{TS zU9S-c)3uwJ1~R#z@J;$Rx`vt!izn{h4cL^U!AIbo9NxWI8N*cexEi^G6Q;X^2fysj z|6I6)tVe@u*VZ6#`GI>Eg(ndR#!X1#1cO7{OiU>-7!AIHMn-NCk@RQ6$5(Iw&8zSX zi)^;|X!~Rv(==P`tUbwIKO@&8M{#C3#pTyE!0>x%Q>*FQ4Nl-Ss$;TdVHV50h_;pc zT;BV+aVz@uyv<*S$tpbJGcbQ?s;WL!($AEu&y;KZ3{eZ6?Y;tCKS$KX(*?wf{ajML mdHQBwfv!IvQ&&%64s`v3rsg`pL)Xv5)a8!-H}K2y{Qm;U2i?E` literal 0 HcmV?d00001 diff --git a/backend/app/schemas/__pycache__/usage.cpython-312.pyc b/backend/app/schemas/__pycache__/usage.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..22b1a26145b5e49c571ad7451051e96594737bd7 GIT binary patch literal 1435 zcmZux&uIWLQg0;Sc4FUo_a%yxkNqi-mI+zla= z|F466O31%BnLg!-vG*K|SA-KzL(;>s6w;{FD@C+NDbB6Xidc_PQX)SP&R!5+7VIyp z*xxG~wgPPR5L+><4eZ<@wrbdUU~7k1dq|!7e|TKSDQU+2OlTW13nJlAJ$KVrVlCk! z>=$gc%XUgSbGtAR#1B@vf zB(b~Nd2n~Fv$}H2eGHt4x$CE?tNH^G`O3{Pk8jhVtis_2DqyG+0Kb#b@sm63bC(4DS!;)j^6I1za`dNQ)OA0JmRY0DBP#vctvF&6H zp$?!~7=Sb;z6B(YA(%jpqqR5%bXE-j2)xnSy7snlewUpuJkjaskiUbK*^s}gKX!tt zKWDuPB*T7%aDXdMdZKdB6Az$vO51%%Y9oQ}sO3sX&)?jDe$dB)SY<($XJW#1bknS- zLiRy{AQVn@CVz=3Nz7cApM;5@$+JkzsBjuN1mx8P08n9}_1oI0)!t$4QTxmeJ2Sd; z<@w#M?)FcAKH6nh3T1H5(aPL`l`oZfY6CU%OiL?Jm*Q)w3VTxYgKwsNKMBJi-dNcb z5<2d_P-zk?sJlr&;JOA91PS-JKUBJ`P{ocjH=~7Sc@Dm;+g-hFB#})BN461+0yC;C z;{w7dc-1)o(;F|>|5!&+ipH)rw;FG2OS^2TPz3$q=8G^$hF}3`I~{T#D%6+w?%O=Fn`}{$d literal 0 HcmV?d00001 diff --git a/backend/app/schemas/__pycache__/user.cpython-312.pyc b/backend/app/schemas/__pycache__/user.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dfd8aafcda0c679a866cbd94542c3294eb3b05d1 GIT binary patch literal 1251 zcmZ`(&2QX96rb_edYuhTvrP$zMv4SdEm4d(SCt9{v=!MP+P(N_Qm>WzAnl)#_8J-nf8fD!NR7{#7CI|LXqo=IOo^?Mi`@9{7c9%~Xqk2q zI(#0E96yBpQzA%AIf<Hh#U0s{9^gh$mau$uocec02qnqzVJ0KG?9{w^Of}@XZ^QJZ+xSF8ov4YNAi^uhv!ejI;dZq2H|v_Z>-QZL+sTD*Z`cZ zNre+92XJN_x^e|wUqo0%xQwudfKI!>uOfTx1kg-jhLF?B@L;>MLG}5UdmA+8UyfTb z{6lcnb|7I$9q! z`~QqK!FSi2|KzrT3Ixnvy$k@kbsf6AfgL(>Hs-;_gRL%CvFVL=ZvsnuqkjP!7$o`t literal 0 HcmV?d00001 diff --git a/backend/app/schemas/calendar.py b/backend/app/schemas/calendar.py new file mode 100644 index 0000000..cec5e00 --- /dev/null +++ b/backend/app/schemas/calendar.py @@ -0,0 +1,70 @@ +import uuid +from datetime import date, time, datetime +from pydantic import BaseModel + + +# ── Timetable Slots ── + + +class TimetableSlotCreate(BaseModel): + day_of_week: int # 0=Monday .. 6=Sunday + start_time: time + end_time: time + title: str + location: str | None = None + + +class TimetableSlotOut(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + day_of_week: int + start_time: time + end_time: time + title: str + location: str | None = None + created_at: datetime + + model_config = {"from_attributes": True} + + +# ── Calendar Events ── + + +class CalendarEventCreate(BaseModel): + event_type: str # "assignment" | "institute_holiday" | "fest" | "club_event" + title: str + description: str | None = None + event_date: date + event_time: time | None = None + end_date: date | None = None + location: str | None = None + source: str | None = None + source_id: str | None = None + metadata_json: dict | None = None + + +class CalendarEventOut(BaseModel): + id: uuid.UUID + user_id: uuid.UUID | None = None + event_type: str + title: str + description: str | None = None + event_date: date + event_time: time | None = None + end_date: date | None = None + location: str | None = None + source: str | None = None + moderation_status: str + created_at: datetime + + model_config = {"from_attributes": True} + + +class AdminEventCreate(BaseModel): + """For club secretaries / coordinators posting events via Admin Panel.""" + title: str + description: str | None = None + event_date: date + event_time: time | None = None + end_date: date | None = None + location: str | None = None diff --git a/backend/app/schemas/career_goal.py b/backend/app/schemas/career_goal.py new file mode 100644 index 0000000..9c312dc --- /dev/null +++ b/backend/app/schemas/career_goal.py @@ -0,0 +1,23 @@ +import uuid +from datetime import datetime +from pydantic import BaseModel + + +class CareerGoalCreate(BaseModel): + title: str + description: str | None = None + + +class CareerGoalUpdate(BaseModel): + title: str | None = None + description: str | None = None + + +class CareerGoalOut(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + title: str + description: str | None = None + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/pomodoro.py b/backend/app/schemas/pomodoro.py new file mode 100644 index 0000000..bb56b6c --- /dev/null +++ b/backend/app/schemas/pomodoro.py @@ -0,0 +1,83 @@ +import uuid +from datetime import datetime +from pydantic import BaseModel + + +# ── Session ── + + +class PomodoroSessionCreate(BaseModel): + focus_duration_min: int = 25 + break_duration_min: int = 5 + total_intervals: int = 4 + + +class PomodoroSessionOut(BaseModel): + id: uuid.UUID + invite_code: str + created_by: uuid.UUID + focus_duration_min: int + break_duration_min: int + total_intervals: int + status: str + current_interval: int + started_at: datetime | None = None + ended_at: datetime | None = None + created_at: datetime + members: list["SessionMemberOut"] = [] + + model_config = {"from_attributes": True} + + +class SessionJoin(BaseModel): + invite_code: str + + +# ── Members ── + + +class SessionMemberOut(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + is_active: bool + is_paused: bool + focus_seconds: int + xp_earned: int + joined_at: datetime + + model_config = {"from_attributes": True} + + +# ── XP & Leaderboard ── + + +class LeaderboardEntry(BaseModel): + user_id: uuid.UUID + display_name: str + total_xp: int + rank: int + + +class LeaderboardResponse(BaseModel): + session_id: uuid.UUID + entries: list[LeaderboardEntry] + + +# ── Badges ── + + +class BadgeOut(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + badge_type: str + awarded_at: datetime + + model_config = {"from_attributes": True} + + +# ── WebSocket Messages ── + + +class WsMessage(BaseModel): + type: str # "tick" | "state_change" | "member_update" | "session_end" | "nudge" + data: dict diff --git a/backend/app/schemas/suggestion.py b/backend/app/schemas/suggestion.py new file mode 100644 index 0000000..9959d4a --- /dev/null +++ b/backend/app/schemas/suggestion.py @@ -0,0 +1,51 @@ +import uuid +from datetime import datetime +from pydantic import BaseModel + + +# ── Suggestions ── + + +class SuggestionItem(BaseModel): + title: str + description: str + priority: float + source: str # "deadline" | "habit" | "goal" + + +class SuggestionResponse(BaseModel): + mode: str + suggestions: list[SuggestionItem] + quote: str + workload_score: float + + +class SuggestionHistoryOut(BaseModel): + id: uuid.UUID + mode: str + suggestions_json: dict + quote: str | None = None + is_completed: bool + is_dismissed: bool + created_at: datetime + + model_config = {"from_attributes": True} + + +class SuggestionStatusUpdate(BaseModel): + is_completed: bool | None = None + is_dismissed: bool | None = None + + +# ── Heatmap ── + + +class HeatmapEntry(BaseModel): + date: str # ISO date + focus_minutes: int + mode: str + + +class HeatmapResponse(BaseModel): + user_id: uuid.UUID + entries: list[HeatmapEntry] diff --git a/backend/app/schemas/usage.py b/backend/app/schemas/usage.py new file mode 100644 index 0000000..bd9f554 --- /dev/null +++ b/backend/app/schemas/usage.py @@ -0,0 +1,26 @@ +import uuid +from datetime import date, datetime +from pydantic import BaseModel + + +class AppUsageEntry(BaseModel): + package_name: str + duration_ms: int + date: date + + +class AppUsageBatchCreate(BaseModel): + entries: list[AppUsageEntry] + + +class AppUsageStats(BaseModel): + category: str + user_avg_ms: float + institute_avg_ms: float + user_percentile: float | None = None + + +class RollingAverageResponse(BaseModel): + user_id: uuid.UUID + period_days: int + stats: list[AppUsageStats] diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..c3b178d --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,25 @@ +import uuid +from datetime import datetime +from pydantic import BaseModel, EmailStr + + +class UserOut(BaseModel): + id: uuid.UUID + email: str + display_name: str + avatar_url: str | None = None + focus_mode: str + role: str + created_at: datetime + + model_config = {"from_attributes": True} + + +class UserFocusModeUpdate(BaseModel): + focus_mode: str # "acads" | "clubs" + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserOut diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/__pycache__/__init__.cpython-312.pyc b/backend/app/services/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..30fc3d21bdea91e0d804613ed93e55f2baa6c9d3 GIT binary patch literal 187 zcmX@j%ge<81h4)q%mmSoK?FMZ%mNgd&QQsq$>_I|p@<2{`wUX^D@s2jKQ~psqOu?( zKebrjCAB!aB)>pEpeR2pHMyi%KP9y+r?fyfBQZHUu_PluPv6ro*x%RB)6rQ!2`HAD zm!h9oP@rF&T2z*q3^X7TZlX-=wL5i8ItMj$Q*F+MUgGBOr1 G16criYBG8N literal 0 HcmV?d00001 diff --git a/backend/app/services/__pycache__/auth.cpython-312.pyc b/backend/app/services/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e9bbed977d2a322ad9ca2d185f04d34c1a9a4edf GIT binary patch literal 4216 zcmb7HZ){W76~FI2|99*-asDj{$pZq!8Tm&;5>iU#kU#^}&<5IKRkB|EZXBEUZ1=ur zk{GkOv|2%(MA4>&X-Gp=KdgdEoiu4os<_?ASauGCWfwYl3BI{EwVLem2E+rK4+5bvLon_ok6G0o244r6?Dn&pj+oH zQmyO>dUV<<)yeh2dY!gOUb!LIpwo7#QEm!0>9j*?mRo`?3}O*Ru>;11DAM3itaLB3p@|f7QDkaOXr#lZk822*eTZG zdN`qF)*{xwZw~I%WpM7U9|Ol=35O|F<5w2r;c2WWu#55$tcDb zjfYghIg3rz9hTCun$lF&aF;Q5q+#|llR+6qWN)#TS=DWb#p(>IGKw6PyhCpdjX|on zNP(6HgQ|=fw(1s5m3f1zT5U1rD&t*~p=TQT%mH{)s~nA|R7`EeDXAHa!wbpuJ}@2_ zF&^Q;Z2WA3@TE6@X^*m>2Z<=b3ed3ME=UouxjHBNT!j7yjf*Gd3AKa9rPMH?Z>-r2 zdusJy{!sCRP)W6BkrI&zSk^4!nN)<(x792-9!`i@b5nT*hr!mN3wTj;QNE&+BlSgT z{Z5)SDJ)6}K~(5Xc%FauM*mzw#{CP6$+-kp`X{h*K}{t4&yYk6yie&D@kJ?>?41+B z7X)zLc>h#j`t+-TDgV*_8JGhA^b5&kKX}~5Xc!I`rFsuzSaHn{XgBm>e{zqtGm^mZ#|f6J-B8Ye!?;Ck?gU@O*?MS z=G?v6;~OT&2dCaWwNf{B@A&c?Yo=2hPWSb(YhxcpZY|_HhjX37zwgeEzm*$*Yt4Cn z#dLlXlpxKltKJ?1865+5^zA6fpl`=P)vArE+}a`x_HQy;A{0fs{r_pYtmeJ9L$na(Ezi72&IOBx> zL$9@3uZS*l-{a<)d9=WtNArx&MiJ&Hd#gg|mD2kus62IWnPX#K#WJyuBrG%L5hmJZs<>0%~=e6l!qaXibQ%XSm+YXFbzUHGxCfyrnNSDN8fmN#4Gj#2&ISDE z&c1T`^{L?G1fhoanaDmWx0`l4G9jLX6&eEojXdcBLh}}NZp9AGr0wuiK7lTa3T&;} zu~FZ+)N`X}y{7KRqj_&n&fD{aEk7E_jRsamPCw;Xuj8t}~t z?ySH1@lObY|6z3h zuzmbR?o0FF_%Qe7uo-C03Gu;5e;O;!gsamJeZ}|SxA{i^=t&6qOH~lfpzTtX0SN#Q z6QjZPxuzNw6H?0{HTG-=$%@o=sZ1@yGUj=O3_ zvl$?f;bP^KRyob6gf5#hrtF|vPIa;7ii|;t0O=}le#e+sTMa5kedoC*vUkgnzQR;# z=zLBPSP^a!Iel1c>y}jiWhJg@-}hNDHQ3B_U5kxlyP&N8aW0A)MqK{aAjNlL&`hF>@TviXaN-%kBzYB{oc zIFK6*tahAU^`6PQ&Sa0RJL~e!uAH-L)wwr2v2Jx{GiChxmq*rYC%5AF)e3%}T9DaU z%(Q)?>V}gxxb$|y3=&O*MATg zas=kLWLz5GbU=R$L=n&cW1ydJB6DE`-tk4X91gYvv&h2k?JCmJF33;+V@)b@Q1O*^ zL0&}DsN|@cSrDQ8N_Phm@m`(OeU*G@s6O)h`^>R;c$V zlhEm&19tvk@!iFbj{n)(R%Py@@hT=C%(V=zjJ;MdIfVKePxj=$6#A%F{4Rej^QL&4b0{ z>QLU11Svd+F0{!xdCJ6mLfI1Af651BhQ5y;sF;3GuExwCag$iFKdX-v?B=m9hSN;@{ zy_D{P?wRc{C`d{82$=?!nn(E^bOnxKn6HrgE988H4&=~*zv=xWH2Da1JVZMlqW-_4 z-49XcL&QI}w_a_|+go$?)|;cN_O7hufwTF;BU#%6XXA&5v$lf83}5lLy#++aUwAk5 zvEcDAj+;sW!EjsIq{EYLG&p+CwmRT{(A;tJ;*Y-n#Oz``>vg>a4#vl}h63fmycWl% h6{sgX+S|L*`_k&3BTtZp@!S*(bXXpGN(cQ<{s%*;s>1*P literal 0 HcmV?d00001 diff --git a/backend/app/services/__pycache__/ws_manager.cpython-312.pyc b/backend/app/services/__pycache__/ws_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..53bad26b3aea9f48226c411886572959c69d8882 GIT binary patch literal 5939 zcmbVQYi!%r6}}WniIVk-?Km$trei0LVq02Xb?c^f94AYYx^@0xh)lG_T&S9h&O21O zHsP|6lSHsyA%g9W#b|TGIQJcceZn(u@o}lA&xw=cQuu6^Gb*JYLt%-C1cXCSFd!7m6#^omMOI+oV@2zf6%NEq*vz&mMzBGh ztEqEPw+ox}7Q5&W9AbmWZiBgDY{M0H!m0N-MHh^90k7K}D`1;da6_9%Z*zza;PF7+ zJMZ;1YTHZ+3|GYTLRm_wD-Iq~xu)=ygVqRC;gcXua|Tm}etq0ASqcf^kfJKQ8VO(E z#fxG>ReE?;4uvl${EQfq)k!e~-FO=@X|8ul3ePIMAgbltDo+zOZeABC4HUo9i$Io$ zTIo{_T_D8Nlr#xe+P;PlXVlX?xlN7w7@38d#^4QWjUmvSi(;^=!1VZYJln9&}9UpsP z^wiLBU=rHIgb)ZNlL1APFGj*J!JHB_RMwxI*SKIXl8C6mU}~2s^2aXCZ}M!04Jn;K zE|br#Wbicgr_S8KSdk`n*L|)Hnx6SK^*bTg|9pn|p{E3VL~|Gz2u1|WHVd1;f_+Y$ zRCFMPP2?jpD)?*gmA1mkpC=UDMw*UR?gLrM$;9PrU@5)_l77RoME`(gVMLlvlW66R zOnySr6e@Pwas;S~SD~k<)htzW)r1*|>h`MIgQcn_M4fAUu(oCux7i=vWR|MAK&7d) z1@_uhZ8J-0%3S4+Asvd$lRk3wm0!|xl+QAn>OMDa0!9@2O^7_ZTj5c{nD133H57Yx zx8U` zcie*ho(G)!TEk+)z2>1p^Uhng<>tI^AnO}gN#%T}|3y>w5vph-_VzCnFD(3O^DzhY z26c>MmWB>MFkyM&R|Jf1Y-IU&`J`m)pL_a*UA5D z^!kONUS3=AZM3szH(0gqdf-T^yHzWwh}s`txU9K%j5DKVqDFRrMhX$dxZC0@;b z)aay?)KyMKOv>AV+@{wlmxA+G)XGtvI@P(cW>vX5V6-v^1T>3t)@V_l3uL*#UE50Y zeQp4{D(aKxy0To?vh6N(JX!EG_w56VfGOYvLe8pD5 zq(-W>LGwgL8wP^MrR6P1aB6ug5=6b+24t`Y8waFBJySDpzx7|>45m%Ns8M0ImyTiWo$L!I?=M5mQ47A_DA& zp2|LH6M8azgyGMWRQJXT{TQB;7Dw)F>t8wjK6~Fe3hWtY z88)^!JT2hau?8q;w_k&=^rLG0RM&6S_W@NEU!cD_MoL>ugb&6Y_Th_lb|YIQZ@UDOBf!)Lsj-cm&_wU*R0v*LUJOKnAYf+ znDE(a!1yifZZO?`?D?k@9tZf%(V<<8Xh#ZEbY6o@8-E&Duw`4u zS=g~BzoRF+qbIk+|A;X5-o@uLC$FC^T4+~qp=Ik*>_+TXU#_J)@9oZdyNfJv6dk0Q ze?%yIZ)VtV`!`Q7(;qr}iV#co{;gpPLUeR-^xlrK``>x7;B76@)LCk0q0I-urmGts zM$WCp2C}K=zYm&PVIo+`+nV=wWW5ls{@IJuxO%@-@KN&WkYm_Jt-6|zby-&1sbjmX ztGgI1?-_*lcXv}zez$A;FvGq_S)k=Tn!++eLwU_33caOn1mHi2`H1>PMxd^dr~y8B zAwPhbiB_Idu(FX{%JoC?fIHrD=HzSB5S5=a>Uu1$GYXV%r3 zckRu(_AYO~dp75KD(^aybsbqbmvf!SSUzfSp^_gql>Fz1e$oHa{^jXh$CJ61zPz_D z>+LJD#OeOT+h?ljVF-DbJU2WL-h!GQLNz`7rGn6V)ql)F{km(Yovd!9hT53bZ48#5 zut4)_8-?Ze9Ycfk>JhzVfWq=14ZY#oS2=nrw6UcTAhppj*!EgJ=*3h|#cD$`Q;o)r zc$b#@U{v+8e3t5+C^)}ieB>Qa{;KeH{UdlB_!S-qgtwl?>xc49`?5{@@=gA1lYcq2 zav;|9RP57H75sHh!pymh$;qTyV49m`7@Riw6%;>QNgMt(e z24#GlYP6!tGRjHDmvY%C@6tOW2~}fa5$K0llgJpZ%4kC50VH^&+eBYqkAGy0h;)Kc zsPZUd5j+Ae4Kt2O)U}}ki8~_!H`iKmEN*X>U?vHi)SJL$t((w=S#3%OcnJszy_N;yT i&8Kqq&db(+TTf8bQR*QnXJ;*>p)=>${e7Fh(*FR1kQ$c& literal 0 HcmV?d00001 diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..637c0d3 --- /dev/null +++ b/backend/app/services/auth.py @@ -0,0 +1,69 @@ +import logging +import uuid +from datetime import datetime, timedelta, timezone + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from jose import JWTError, jwt +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.database import get_db +from app.models.user import User + +logger = logging.getLogger(__name__) +settings = get_settings() +security = HTTPBearer() + + +def create_access_token(user_id: str) -> str: + expire = datetime.now(timezone.utc) + \ + timedelta(minutes=settings.jwt_expire_minutes) + payload = {"sub": user_id, "exp": expire} + logger.debug("Creating JWT for user_id=%s, expires=%s", user_id, expire) + return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) + + +def decode_access_token(token: str) -> str: + try: + payload = jwt.decode( + token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm] + ) + user_id: str = payload.get("sub") + if user_id is None: + logger.warning("JWT decode succeeded but 'sub' claim missing") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" + ) + return user_id + except JWTError as exc: + logger.warning("JWT decode failed: %s", exc) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token" + ) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: AsyncSession = Depends(get_db), +) -> User: + user_id = decode_access_token(credentials.credentials) + result = await db.execute(select(User).where(User.id == uuid.UUID(user_id))) + user = result.scalar_one_or_none() + if user is None: + logger.warning("Authenticated user_id=%s not found in DB", user_id) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" + ) + logger.debug("Authenticated user: %s (%s)", user.display_name, user.id) + return user + + +async def require_admin(user: User = Depends(get_current_user)) -> User: + if user.role != "admin": + logger.warning("Non-admin user %s attempted admin action", user.id) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required" + ) + return user diff --git a/backend/app/services/classroom_sync.py b/backend/app/services/classroom_sync.py new file mode 100644 index 0000000..0d20f33 --- /dev/null +++ b/backend/app/services/classroom_sync.py @@ -0,0 +1,97 @@ +"""Google Classroom sync — pulls assignment deadlines via Classroom API.""" + +import logging +from datetime import date + +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.models.user import User +from app.models.calendar import CalendarEvent + +logger = logging.getLogger(__name__) +settings = get_settings() + + +async def sync_classroom_assignments(user: User, db: AsyncSession) -> int: + """ + Fetch assignment deadlines from Google Classroom and upsert + into calendar_events as type='assignment'. + Returns the number of synced assignments. + """ + creds = Credentials( + token=user.google_access_token, + refresh_token=user.google_refresh_token, + client_id=settings.google_client_id, + client_secret=settings.google_client_secret, + token_uri="https://oauth2.googleapis.com/token", + ) + + service = build("classroom", "v1", credentials=creds) + + # List enrolled courses + courses_resp = service.courses().list( + studentId="me", courseStates=["ACTIVE"]).execute() + courses = courses_resp.get("courses", []) + logger.info("Classroom sync for user %s: found %d active courses", + user.id, len(courses)) + + count = 0 + for course in courses: + course_id = course["id"] + course_name = course.get("name", "Unknown Course") + + # List coursework + cw_resp = ( + service.courses() + .courseWork() + .list(courseId=course_id, orderBy="dueDate asc") + .execute() + ) + coursework_list = cw_resp.get("courseWork", []) + + for cw in coursework_list: + due = cw.get("dueDate") + if not due: + continue + + due_date = date( + year=due.get("year", 2026), + month=due.get("month", 1), + day=due.get("day", 1), + ) + + source_id = f"classroom:{course_id}:{cw['id']}" + + # Check if already exists (upsert logic) + existing = await db.execute( + select(CalendarEvent).where( + CalendarEvent.source_id == source_id) + ) + if existing.scalar_one_or_none(): + continue + + event = CalendarEvent( + user_id=user.id, + event_type="assignment", + title=f"[{course_name}] {cw.get('title', 'Assignment')}", + description=cw.get("description"), + event_date=due_date, + source="classroom", + source_id=source_id, + metadata_json={ + "course_id": course_id, + "coursework_id": cw["id"], + "max_points": cw.get("maxPoints"), + }, + moderation_status="approved", + ) + db.add(event) + count += 1 + + logger.info( + "Classroom sync complete for user %s: %d new assignments", user.id, count) + return count diff --git a/backend/app/services/gmail_parser.py b/backend/app/services/gmail_parser.py new file mode 100644 index 0000000..9038680 --- /dev/null +++ b/backend/app/services/gmail_parser.py @@ -0,0 +1,174 @@ +"""Gmail club event extraction — fetches emails, parses with LLM, inserts events.""" + +import json +import logging +from datetime import datetime + +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.models.user import User +from app.models.calendar import CalendarEvent +from app.services.llm import call_llm + +logger = logging.getLogger(__name__) +settings = get_settings() + +GMAIL_EXTRACTION_PROMPT = """You are an event extractor. Given the following email body from a college club or organization, extract any event information. + +Return a JSON object with these exact fields (use null if not found): +{ + "event_name": "string", + "date": "YYYY-MM-DD", + "time": "HH:MM" (24-hour) or null, + "location": "string or null", + "type": "club_event" +} + +If this email does not contain an event, return: {"event_name": null} + +Email body: +--- +%s +---""" + +# Known club sender patterns (configurable — FR-07 mentions "known club sender patterns") +CLUB_SENDER_PATTERNS = [ + "club@", + "committee@", + "council@", + "fest@", + "hackathon@", + "ieee@", + "acm@", + "gdsc@", + "coding@", + "cultural@", + "sports@", + "tech@", +] + + +async def parse_gmail_club_events(user: User, db: AsyncSession) -> int: + """ + Fetch last 100 emails, filter by club senders, LLM-extract events, + validate with Pydantic, and upsert into calendar_events. + Returns the count of newly inserted events. + """ + creds = Credentials( + token=user.google_access_token, + refresh_token=user.google_refresh_token, + client_id=settings.google_client_id, + client_secret=settings.google_client_secret, + token_uri="https://oauth2.googleapis.com/token", + ) + + service = build("gmail", "v1", credentials=creds) + + # Build query for club-related emails + query_parts = [f"from:{pattern}" for pattern in CLUB_SENDER_PATTERNS] + query = " OR ".join(query_parts) + + messages_resp = ( + service.users() + .messages() + .list(userId="me", q=query, maxResults=100) + .execute() + ) + messages = messages_resp.get("messages", []) + logger.info("Gmail sync for user %s: found %d candidate messages", + user.id, len(messages)) + + count = 0 + llm_calls = 0 + MAX_LLM_CALLS = 10 # NFR-10: cap at 10 LLM calls/user/day for Gmail parsing + + for msg_meta in messages: + if llm_calls >= MAX_LLM_CALLS: + break + + msg = ( + service.users() + .messages() + .get(userId="me", id=msg_meta["id"], format="full") + .execute() + ) + + # Extract plain-text body + body = _extract_body(msg) + if not body: + continue + + source_id = f"gmail:{msg_meta['id']}" + + # Skip if already processed + existing = await db.execute( + select(CalendarEvent).where(CalendarEvent.source_id == source_id) + ) + if existing.scalar_one_or_none(): + continue + + # LLM extraction + prompt = GMAIL_EXTRACTION_PROMPT % body[:3000] # cap body length + llm_calls += 1 + + try: + raw = await call_llm(prompt) + parsed = json.loads(raw) + except (json.JSONDecodeError, Exception) as exc: + logger.warning( + "LLM extraction failed for gmail msg %s: %s", msg_meta["id"], exc) + continue # NFR-05: discard invalid LLM output + + if not parsed.get("event_name"): + continue + + try: + event_date = datetime.strptime(parsed["date"], "%Y-%m-%d").date() + except (ValueError, KeyError, TypeError): + continue + + event = CalendarEvent( + user_id=user.id, + event_type="gmail_event", + title=parsed["event_name"], + event_date=event_date, + event_time=( + datetime.strptime(parsed["time"], "%H:%M").time() + if parsed.get("time") + else None + ), + location=parsed.get("location"), + source="gmail", + source_id=source_id, + moderation_status="approved", + ) + db.add(event) + count += 1 + + logger.info("Gmail sync complete for user %s: %d events inserted, %d LLM calls used", + user.id, count, llm_calls) + return count + + +def _extract_body(msg: dict) -> str | None: + """Extract plain-text body from a Gmail message.""" + import base64 + + payload = msg.get("payload", {}) + + # Simple message + if payload.get("mimeType") == "text/plain": + data = payload.get("body", {}).get("data", "") + return base64.urlsafe_b64decode(data).decode("utf-8", errors="ignore") + + # Multipart + for part in payload.get("parts", []): + if part.get("mimeType") == "text/plain": + data = part.get("body", {}).get("data", "") + return base64.urlsafe_b64decode(data).decode("utf-8", errors="ignore") + + return None diff --git a/backend/app/services/llm.py b/backend/app/services/llm.py new file mode 100644 index 0000000..005ff98 --- /dev/null +++ b/backend/app/services/llm.py @@ -0,0 +1,37 @@ +"""Shared LLM service — wraps Gemini (primary) with error handling.""" + +import logging + +import google.generativeai as genai + +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + +# Configure Gemini +genai.configure(api_key=settings.gemini_api_key) +model = genai.GenerativeModel("gemini-2.0-flash") + + +async def call_llm(prompt: str, max_tokens: int = 1024) -> str: + """ + Send a prompt to the LLM and return the text response. + Raises on failure — callers should handle exceptions (NFR-05). + """ + logger.info("LLM request: prompt_len=%d, max_tokens=%d", + len(prompt), max_tokens) + try: + response = model.generate_content( + prompt, + generation_config=genai.types.GenerationConfig( + max_output_tokens=max_tokens, + temperature=0.7, + ), + ) + logger.info("LLM response received: response_len=%d", + len(response.text)) + return response.text + except Exception: + logger.exception("LLM call failed") + raise diff --git a/backend/app/services/suggestion_engine.py b/backend/app/services/suggestion_engine.py new file mode 100644 index 0000000..33de2c8 --- /dev/null +++ b/backend/app/services/suggestion_engine.py @@ -0,0 +1,138 @@ +"""Suggestion scoring engine — assembles context, calls LLM, returns ranked items.""" + +import json +import logging +import uuid +from datetime import date, timedelta + +from sqlalchemy import select, and_, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.career_goal import CareerGoal +from app.models.calendar import CalendarEvent +from app.models.suggestion import SuggestionHistory +from app.schemas.suggestion import SuggestionResponse, SuggestionItem +from app.services.llm import call_llm + +logger = logging.getLogger(__name__) + +SUGGESTION_PROMPT = """You are a smart campus focus assistant called FocusForge. +Given the user's context below, return a JSON object with: +1. "suggestions" — an array of 3-5 ranked action items the user should focus on RIGHT NOW. + Each item: {"title": "...", "description": "...", "priority": 0.0-1.0, "source": "deadline|habit|goal"} +2. "quote" — a short motivational quote personalised to their goals and workload. +3. "workload_score" — a float 0.0-1.0 indicating workload intensity. + +Focus Mode: %s +Career Goals: %s +Upcoming 48h Events: %s +Assignment Count (next 7 days): %d +Current Date: %s + +Respond ONLY with valid JSON, no markdown fences.""" + + +async def generate_suggestions( + user_id: uuid.UUID, mode: str, db: AsyncSession +) -> SuggestionResponse: + """ + Build context payload, call LLM, parse and cache result. + Falls back to a static response on LLM failure (NFR-05). + """ + logger.info("Generating suggestions for user %s, mode=%s", user_id, mode) + today = date.today() + window_end = today + timedelta(days=2) + week_end = today + timedelta(days=7) + + # Gather career goals + goals_result = await db.execute( + select(CareerGoal).where(CareerGoal.user_id == user_id) + ) + goals = [g.title for g in goals_result.scalars().all()] + + # Gather upcoming 48h events + events_result = await db.execute( + select(CalendarEvent).where( + and_( + (CalendarEvent.user_id == user_id) | ( + CalendarEvent.user_id.is_(None)), + CalendarEvent.event_date >= today, + CalendarEvent.event_date <= window_end, + CalendarEvent.moderation_status == "approved", + ) + ) + ) + events = [ + {"title": e.title, "type": e.event_type, "date": e.event_date.isoformat()} + for e in events_result.scalars().all() + ] + + # Count assignments in next 7 days (workload density) + assignment_count_result = await db.execute( + select(func.count()).where( + and_( + CalendarEvent.user_id == user_id, + CalendarEvent.event_type == "assignment", + CalendarEvent.event_date >= today, + CalendarEvent.event_date <= week_end, + ) + ) + ) + assignment_count = assignment_count_result.scalar() or 0 + + # Build prompt + prompt = SUGGESTION_PROMPT % ( + mode, + json.dumps(goals) if goals else "None set", + json.dumps(events) if events else "None upcoming", + assignment_count, + today.isoformat(), + ) + + try: + raw = await call_llm(prompt) + # Strip markdown fences if present + cleaned = raw.strip() + if cleaned.startswith("```"): + cleaned = cleaned.split("\n", 1)[1] + if cleaned.endswith("```"): + cleaned = cleaned.rsplit("```", 1)[0] + data = json.loads(cleaned) + logger.info("LLM returned %d suggestions for user %s", + len(data.get("suggestions", [])), user_id) + except (json.JSONDecodeError, Exception) as exc: + # NFR-05: Fallback on LLM failure + logger.warning( + "Suggestion LLM call failed for user %s: %s — using fallback", user_id, exc) + data = { + "suggestions": [ + { + "title": "Review upcoming deadlines", + "description": "Check your calendar for any assignments due this week.", + "priority": 0.8, + "source": "deadline", + } + ], + "quote": "The secret of getting ahead is getting started. — Mark Twain", + "workload_score": 0.5, + } + + suggestions = [SuggestionItem(**s) for s in data.get("suggestions", [])] + quote = data.get("quote", "Stay focused!") + workload_score = float(data.get("workload_score", 0.5)) + + # Cache in suggestion_history + history = SuggestionHistory( + user_id=user_id, + mode=mode, + suggestions_json=data.get("suggestions", []), + quote=quote, + ) + db.add(history) + + return SuggestionResponse( + mode=mode, + suggestions=suggestions, + quote=quote, + workload_score=workload_score, + ) diff --git a/backend/app/services/ws_manager.py b/backend/app/services/ws_manager.py new file mode 100644 index 0000000..6d25233 --- /dev/null +++ b/backend/app/services/ws_manager.py @@ -0,0 +1,88 @@ +"""WebSocket connection manager for Group Pomodoro sessions.""" + +import uuid +import asyncio +import json +import logging +from datetime import datetime, timezone +from collections import defaultdict + +from fastapi import WebSocket + +logger = logging.getLogger(__name__) + + +class PomodoroConnectionManager: + """ + Manages WebSocket connections per Pomodoro session. + Broadcasts tick events, tracks heartbeats for low-focus detection. + """ + + def __init__(self): + # session_id -> {user_id: WebSocket} + self.active_connections: dict[str, + dict[str, WebSocket]] = defaultdict(dict) + # session_id -> {user_id: last_heartbeat_time} + self.heartbeats: dict[str, dict[str, datetime]] = defaultdict(dict) + + async def connect(self, session_id: str, user_id: str, websocket: WebSocket): + await websocket.accept() + self.active_connections[session_id][user_id] = websocket + self.heartbeats[session_id][user_id] = datetime.now(timezone.utc) + logger.info("WS connected: session=%s user=%s (total=%d)", + session_id, user_id, self.get_member_count(session_id)) + + def disconnect(self, session_id: str, user_id: str): + self.active_connections[session_id].pop(user_id, None) + self.heartbeats[session_id].pop(user_id, None) + if not self.active_connections[session_id]: + del self.active_connections[session_id] + self.heartbeats.pop(session_id, None) + logger.info("WS disconnected: session=%s user=%s", session_id, user_id) + + def record_heartbeat(self, session_id: str, user_id: str): + self.heartbeats[session_id][user_id] = datetime.now(timezone.utc) + + def get_stale_members(self, session_id: str, grace_seconds: int = 30) -> list[str]: + """Return user IDs with heartbeats older than grace window (low-focus).""" + now = datetime.now(timezone.utc) + stale = [] + for uid, last_hb in self.heartbeats.get(session_id, {}).items(): + delta = (now - last_hb).total_seconds() + if delta > grace_seconds: + stale.append(uid) + if stale: + logger.debug("Stale members in session %s: %s", session_id, stale) + return stale + + async def broadcast(self, session_id: str, message: dict): + """Send a message to all connected members in a session.""" + connections = self.active_connections.get(session_id, {}) + payload = json.dumps(message) + disconnected = [] + for user_id, ws in connections.items(): + try: + await ws.send_text(payload) + except Exception: + disconnected.append(user_id) + for uid in disconnected: + self.disconnect(session_id, uid) + if disconnected: + logger.warning("Broadcast: %d members disconnected from session %s", len( + disconnected), session_id) + + async def send_to_user(self, session_id: str, user_id: str, message: dict): + """Send a private message to one member (e.g. accountability nudge).""" + ws = self.active_connections.get(session_id, {}).get(user_id) + if ws: + try: + await ws.send_text(json.dumps(message)) + except Exception: + self.disconnect(session_id, user_id) + + def get_member_count(self, session_id: str) -> int: + return len(self.active_connections.get(session_id, {})) + + +# Singleton instance +manager = PomodoroConnectionManager() diff --git a/backend/main.py b/backend/main.py index e69de29..d907aec 100644 --- a/backend/main.py +++ b/backend/main.py @@ -0,0 +1,5 @@ +import uvicorn + +if __name__ == "__main__": + print("[FocusForge] Starting uvicorn server on 0.0.0.0:8000") + uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..cef513c --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,17 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +sqlalchemy[asyncio]==2.0.35 +asyncpg==0.29.0 +alembic==1.13.2 +pydantic==2.9.2 +pydantic-settings==2.5.2 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +httpx==0.27.2 +google-auth==2.35.0 +google-auth-oauthlib==1.2.1 +google-api-python-client==2.149.0 +google-generativeai==0.8.3 +python-multipart==0.0.12 +python-dotenv==1.0.1 +websockets==13.1 From 00faad0f9ca81072b59bdc0551fc656b49d506cc Mon Sep 17 00:00:00 2001 From: YugDalwadi Date: Sat, 28 Feb 2026 05:46:43 +0530 Subject: [PATCH 2/4] feat: tested routes --- backend/.gitignore | 5 ++- backend/app/__pycache__/main.cpython-312.pyc | Bin 3705 -> 3705 bytes backend/app/main.py | 2 +- .../routers/__pycache__/auth.cpython-312.pyc | Bin 5551 -> 7227 bytes .../__pycache__/calendar.cpython-312.pyc | Bin 14295 -> 14295 bytes .../__pycache__/pomodoro.cpython-312.pyc | Bin 27331 -> 28240 bytes backend/app/routers/auth.py | 38 ++++++++++++++++++ backend/app/routers/pomodoro.py | 22 ++++++++-- .../__pycache__/suggestion.cpython-312.pyc | Bin 2167 -> 2182 bytes backend/app/schemas/suggestion.py | 2 +- .../services/__pycache__/llm.cpython-312.pyc | Bin 0 -> 1871 bytes .../suggestion_engine.cpython-312.pyc | Bin 0 -> 6498 bytes backend/app/services/llm.py | 2 +- frontend/dummy.txt | 0 14 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 backend/app/services/__pycache__/llm.cpython-312.pyc create mode 100644 backend/app/services/__pycache__/suggestion_engine.cpython-312.pyc delete mode 100644 frontend/dummy.txt diff --git a/backend/.gitignore b/backend/.gitignore index 0e47061..8228be3 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,4 +1,7 @@ .env venv/ venv -.venv/ \ No newline at end of file +.venv/ +test.txt +*.txt +*.csv \ No newline at end of file diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc index 649095a3f9725e82defd3aa1b8e8a7b5bb3a9092..5d864e975af4ad2cd18b6547ce1fb40e6fb84585 100644 GIT binary patch delta 22 ccmew<^HYZRG%qg~0}y=XT$HJ@kvE4A08$VJiU0rr delta 22 ccmew<^HYZRG%qg~0}w1{UX-c2kvE4A08cpvIRF3v diff --git a/backend/app/main.py b/backend/app/main.py index 7170728..7294596 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -72,4 +72,4 @@ async def health_check(): app.include_router(calendar_router.router) app.include_router(suggestions_router.router) app.include_router(usage_router.router) -app.include_router(pomodoro_router.router) +app.include_router(pomodoro_router.router) \ No newline at end of file diff --git a/backend/app/routers/__pycache__/auth.cpython-312.pyc b/backend/app/routers/__pycache__/auth.cpython-312.pyc index 205ee8be7231f51cf88df31fed8d9c740e93a336..29348a622a8c1864893ed741c644b48ccc4c95cd 100644 GIT binary patch delta 1772 zcmZuxZ){Ul6u-Ca>+2udb?r*qm6bQvZ4}&JHH@Lapj+kdR0cW~X6d^fZQ8zi-|K|h zWpxk(h6I;KWC>!7@&U3LMrI=F2Ll=t;}@1;RvSzf!v{Z^DkKsU6VL4^1L95Y@1A?k z@1A$=Ip@AB>%Zx;{$#P35R4a1pT-6q6IM5V^J=q*aY&soIzn#Znzm1A7y%*CBi4#u z4I*0Nn8HiCs7_o({9+wUAIz1+6g7yePjFL9X7pMU5#s&K7Z%h{aGk53IHMYI%_EGp zz}Q!2GytQv{fQ4~L_hI}aIEZHFE&1Mblou=XqxTR^cwNy*`ww|7S(D{H z9yYa!WTYh_4aZX18imM8MkaJCIT}u;6QngFC9}o>&%;PZcMj%fY>)Md(A#IqiEKhemZdUA$VI<7?YT zxg3`tP|THcqCSVtqYIiG%Hf%pl`_Kk-`bU_fpKIU7ffboaIAaoE1bYt%{d;#03cuyg3WtxS!?G-d zBxyK7g#Mn4a->z*PDvOVMUbcvjip3c2n(!dfGm(?IF=~838zGX5+y@Z@)!_yzB14% zyiAhP2oaPcFDF%wZ@QmVD=ETAcxp-V|Z zN++TFiahI6;Kt6v3(Fe!N{SGbGAWU+gIny)>R3k?xo{*b$^niBJ3+hLv9uqyMN?FY zWaOcwB$BKzNJf^f5Gh|x>=qhjVJ&Oy=Nsif)(duZ$m|}Y8{lZbK{qiEd^nvRBB>F| z8iH00{6;1WeZ=G=}IdM(AR& z8$f2Sg%u43Wj#_f!qtb$kC`5T{Xs_h7vOhvKacLML=F8fvwbwFp9l?2D@BAi5--4wt-&ZhK zFPQyvX8*joDIZ+q&H3EzDo4S+YI6T%=z~=q(OGEoJ zGvwO9&xUPx4AwK|Q|6mB9W&2f^MBj?Rr4)S+8QoyzofguU*kT;AqPy+J!KCH#~+Xup8htAE?y$KC3<yxo8<$%0-g(@$SlwK3Hxz81UtGS) w5q0cO;~K0ln5`u(tc!MMiG#JIN1)-EWI#seUyO5Ci!>Wc8m8N+KIJ_AAKMh9!T{eYa<&96j{F|yxc zO-xQqDW1GlJeubgS6Y5@X>ojReoE?OQ3*vgSD?BgTM*#}BHTfQ0Ehsa#$4nNVw+En zk41^5$2MQ6iJDsDDlwALzE>`QgzstY^zitHp#!Zc=|_?{%ve0=IY z%2y7iZki^AkVxHJZH&OVnx=Nq7DSsL35qz5lcqt72u7ObA*~w}{Slyt+MsBX7D#87 zqNoTi;Qr_X-nTQev$MN1v$K2g9rE`3#QB!PVK>0{eB|lmOE)e!Ye>sNYc59&l0hm< z`xJl7uhhh9IB!kYDs{0sPTSJ;N<*wcX^b^0fmlEZ#)4dKPluFnEX?WhbdwT^MU>`P zv$8I>PHBm?D6O$pWqoYD5{*TT=FstHD)Pw-&AThvaQ?v=+90&9=i=S#i@U+uOnR9k`=N zq!z~L z_?NX5sp1Y&_3U4cMq*(HoegY!gO#~%xB61yqFSlVTG$7{Mk269i-)~d=V$4Vll{mk zutTnT=Uqa!xu}|IMUSz~)~)Qv8v|_K>Zq6}bH0AV+^^t|v1hD5AwG7iEtK=Z5Z#!R z=msF@Mubf$GKrF;nx!h%I@QSVS z0sq4dXViBCy^Dd~%kI9*w*D(N=L6ok+`F#uqAPs%=I3Ki#TH#%mu=l2l?fipKMjJ@ za?i+;-Qe=R>kKYBgO{D*d-gDQd3P>iFnd3N9<%r3B@3v1s%?SUPY;Z`0!Cqi4K~G4yWEu1U^Ll_Fh7eEryJl4Ig-X?~<;`kcDK(ji61pA? zCzfjxkOqUzV6Ym#2E<1Xn%P1y!#;BrbD)YH^=`{QQt@XayW;6)XPN_$A}=+s1bn73VH>6QzaW{1D0aCL9gqA_Zf=&-sjGD;>C)u}IxZoP{19P#3?As<>Y#s6db7>pdHy~WvKG*`x8%<=C2ya9N zdx3eghK!noH){t&!2DtxK^#DQi*Pg%Y!n_Mo?x{mE%#{SF`dVr4~c zZu1XJ$4OKeqx-=eJ%BI<5Up67B5Q)pGX^t+9z?)pt%|t0kU^!2QHVGwX=VT(Y7BI( zK`)<@vnzqV6*})ht$W!+VK2m4*nABo;;Q9=<*I`0MPF6G%?m*lSUo+serO%J&_;$L z!iDxNK%Z)7k9&f7i{D`mV{+>iQc{~qiznk5QIYBGC=#fcIT|(6KDMbLT3I?cc!|({ zrZm)+b7$}q(#IAWZaCt>t`+C#7I9`q&PX(f;x7Z}rer3|t?UkT~Y9}7M~a;5dw z;9$N|evB1lrx7v$P|f+3pivb?Aq0MQ&4ok%`AF(y;`4Dd=yqJngeXnP8odifzYf1P zXUNZohiy*oT8jwxv6`Vbu@Bm+$v4%omRr$sF4vNHQUbqHdO!5&Rz;hN zXHU+^g=>u`=^l2lqdLIdTUD(7@#FG@rY4TcS$Y=6y_Y}Tab6%#=DT_xG*ykGCG(`3 znAPYJpii~1ukQD-KWvH|ID}nRh_y`NGNtzc8?DjHMD1iIkyPoL9hNHbO6t%B7(#mx z4zU}0`@Cn6EY;WuvWz|18yevI<&k|7h2KTsmo?wj2(X2Vxlm?#m8FlP62gZ3zk7Fk zy|{e(G{Q3o&m!E%4v+4(;7;gs?5WX(T0RG#Z4!jKSxktM78T$qjP5^7`q|U_+j9Iu zh;A&b;u^NO=5b?&BL{C1KI!u~Gv9V$v&cS-z`gJgF2T)9NLEbC@xoIir|~NYUx67Z zo>k+snoRjKsc?^#eD!U#^c{pJ(2`k8%kqqFOKNe<)?+fg1N-=ycp0geP|J18czk^K zq4?gh8+RW%Jh-3wv5Vh}7NinUu>)fFp|~}78_0PgM}1d4e%yI0ZqZGOc!KiUQhFl1hb9gp zta1vnKSRJ9fUfb*8IVO?=!lKWY5Pmm+6kbWvg!;yj4VDDXaZ&BaE8$ll6X?1@3D6# z#w+>0FQEp4)tK*1{M_gsrWj(q3}3m)%oN?soRi0QIJK{&#dKm?R!(-RR4KfYE1*nw zN@7-=5FuTv&=RJu?m{_#5^J42kv!~&leN3}arL1V4?ns<_pDMaL=EQUdo6TnIHjm{ z77Gh=PX&TBghSmp6y`maOvqZNIGdfOVKm;v_D(gz%NA5@kmvgguXSsj-%#;5KL+sh zR`KaiFQS?-qk?zv?ad!f^iiaEj^B#Z39ge)f({mHdN%?lB0Y$ z8BiaY491(uk`aZw2zzb1rEMeXdzVbma(!r*^?^eDQP4+Wnt76~oeog1^gL!;uq~N@ Z=0l)e9s(2&IRryc*p^Qw4-)~G{~r^iB&YxY delta 3858 zcmb7Gdu&tJ8NbKAc49lRlQ@oJ=jAxW!3oNvKnM^Z4g{3)3V~AU3fvptli=ju*gDrH zK+!T$x^5bxprfvxP`A>tO$?@K^)_`|rIuzDt&mtzS25jz)c^Kh7Y%Ktkyh>dj`IRB ziCyXb?)e_)JLi0_b3eUHW}|o8nFSiuek>Io_;C z;t{;;zDTLbncaED#N|HEDlmBuxkRkO6q+GSSPEn2Ok z%}>0`$62jkX0>O*>beHHp0)(w=ud1r^q3ttF0;J>Y)|2dn!*clfT)zJO$*d0QCCox zy12(cGwp@gM3&juv?TR~Xs_BLG*+IG zd5i58V(GTzziJqN=8CRLdDhnGYFuEj>0ynJK3xN7F2bEVrIksg^jV zXem|6(x{z9aega88$t(a6&chpQt3(4u2X%4GQ$qska*^v<72LI*H&OO!)x4=_+8g! z{nLDG2i%pI`EO zdAhWywgo07=Zgu3lH`E*C3x?@BlCOyz5Gy9!#i$jCCv*C;w#-{ZX#uIIq{4~A>Nhz zT*YqE#@qYq(2GIz;(2oRKLU?}H$R6r1MhRZ-Ww(>dCc2S+W8s4HT;IRyUuSG(c?6k z%TiVfW|{D~ZR#jxly&gR%B~UgFI$VS0l>5yNkvl_b0eo4VIx8>%8D2rWfWqv30a#F zFcfSHK(zP~|C@GD&1qTI$A48>+Zh8LqZpvz{7{JX17Vhor&E)O`5>_a!0Ln7_=@lk z0@Zo1?-VI_mA=~gz1H*3e&(#ZBiDT@*YUqsbx-dnR=54@*`TEou&k)e{$RFNM9nVQ z>gS9jVC8hIWYABpyUCzSzV7Mw12GdJgKl}IrN0u0k2aFQa`~g)SOkb0VKV5IZ!}fo z7z94~MsKVPh)-ZXV7WlAKpz<-e*PnW6Isi@3;Q;5*wZ*(3=i9hBnE@+Lcr8n=4V?WSW9`n z59NITh7CZtx8PgX7|jaTx}gBrK~zJn41YfmSaGMg`F3&h<@B9M&+SOhyHDSaZ2zoy z$9*gK^DD{Xwtsv3teuwyyXs4Um@_J1=INLvwuM}mNo=WM`-^MFRCv(#k4D#44qV2J+coA1F6ZZSdrBM8FfA4Cet}Z=|sZFDlE&;Ww5dI zl)(~c;0XUiC|V;D(t}RZsG?Ffl2(|?a-i^?{5PS05YnChGJKItFP*`UlSQ~hrHms< z5hehj_lQz5n1-wd1W_rrLR~!^L6eL;8b2p)WkgZOsKHLc?BBs_oRy$5D)Y6?9geVY zxm`(SF}}kfy<#!t?1>CDWoU`Y0d|J#?cHsSATb>S$CET8G_iWkqA7(Z!huq;vYgAMR8xNDnZa#r7gsu3hhIdQ=q0BXwcPW? zok*$Rea2n}iCL^0V+q`8p;e0%79GL~(uEFzJ%ZossJ6Wc%un)j9oJ-XG5_;5=d7iJ zXv8*}PUZ~uJn!%6-Mw3wg&d#^t^|g?Hd<{KDaMJ(WGc-bITNy{5eE1ve!_K5YA`U5BPk~BmHB9 zw-7ENyu@$q8L;C?*xNj?_d-CdAy!KQ)3hl`MKz)_>*h0i50Y*k9BQ35i{@{(=<32w zp5UlmxWrQk;<~j#iNftF7QT#JarDB0k-UH)JaHCAX1VAqSw*7>o$~jG?9+G^m?Z`j zWFnhRXVXK~z58*GXdh>wOL61s)jQ>%pGEt}H-beri~+2u|Tbgy9+B z^Jn-N1OU_d8?_6ri%z-fPbwv@*hhS)5^zcuJBY#l$Hn%Z9iy}BJ6(ue|?QI3-^M@>#l|Df1)B;-sQ{IL}c;QtEa z2qLQw;wT4T%32C^uxYaw5kzl!6GyKhh=f7MU6I&dkb&-;EAEBU<}|vQ{R>FU7~?(u z-H8bQ^+c}KPrCo=Y?!se_Y0?c)&}2MyX5fwvj|4_t&-(gGHXHR8op19w6>$VZ`KN5 zp$^}9br_-gPEbeY2*0GYw7WpPtnzio1;?xv#$pP5=cm93rwqdsWJdD;)(#RGH~wFT CLW$%6 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 8d4a3fe..d76a97e 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -90,6 +90,44 @@ async def google_auth( ) +@router.post("/dev-login", response_model=TokenResponse) +async def dev_login( + email: str = "testuser@example.com", + display_name: str = "Test User", + role: str = "student", + db: AsyncSession = Depends(get_db), +): + """ + DEV ONLY — bypass Google OAuth. Creates or finds a user by email + and returns a JWT. Remove this endpoint before deploying to production. + """ + if settings.app_env not in ("development", "testing"): + raise HTTPException(status_code=404, detail="Not found") + + result = await db.execute(select(User).where(User.email == email)) + user = result.scalar_one_or_none() + + if user is None: + user = User( + google_id=f"dev-{email}", + email=email, + display_name=display_name, + role=role, + focus_mode="acads", + ) + db.add(user) + await db.flush() + logger.info("Dev user created: %s (role=%s)", email, role) + else: + logger.info("Dev user logged in: %s (%s)", email, user.id) + + jwt_token = create_access_token(str(user.id)) + return TokenResponse( + access_token=jwt_token, + user=UserOut.model_validate(user), + ) + + @router.get("/me", response_model=UserOut) async def get_me(user: User = Depends(get_current_user)): """Get current authenticated user profile.""" diff --git a/backend/app/routers/pomodoro.py b/backend/app/routers/pomodoro.py index 6a060ac..4ffd265 100644 --- a/backend/app/routers/pomodoro.py +++ b/backend/app/routers/pomodoro.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, Query from sqlalchemy import select, and_, func from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.database import get_db, AsyncSessionLocal from app.models.user import User @@ -56,6 +57,14 @@ async def create_session( db.add(member) await db.flush() + # Eagerly load members for serialization + result = await db.execute( + select(PomodoroSession) + .options(selectinload(PomodoroSession.members)) + .where(PomodoroSession.id == session.id) + ) + session = result.scalar_one() + logger.info("Pomodoro session created: id=%s, invite=%s, by user %s", session.id, invite_code, user.id) return PomodoroSessionOut.model_validate(session) @@ -104,8 +113,13 @@ async def join_session( db.add(member) await db.flush() - # Refresh session with members - await db.refresh(session) + # Eagerly load members for serialization + result = await db.execute( + select(PomodoroSession) + .options(selectinload(PomodoroSession.members)) + .where(PomodoroSession.id == session.id) + ) + session = result.scalar_one() logger.info("User %s joined pomodoro session %s via invite %s", user.id, session.id, body.invite_code) return PomodoroSessionOut.model_validate(session) @@ -118,7 +132,9 @@ async def get_session( db: AsyncSession = Depends(get_db), ): result = await db.execute( - select(PomodoroSession).where(PomodoroSession.id == session_id) + select(PomodoroSession) + .options(selectinload(PomodoroSession.members)) + .where(PomodoroSession.id == session_id) ) session = result.scalar_one_or_none() if not session: diff --git a/backend/app/schemas/__pycache__/suggestion.cpython-312.pyc b/backend/app/schemas/__pycache__/suggestion.cpython-312.pyc index e37fb9155623445d82319a9106ba1da26d1ac4f2..f86e620087cba2a6aa25c08e15aa8b2ca762f1f0 100644 GIT binary patch delta 163 zcmew^&?d-xnwOW00SJT?7G>_+$eYc~*f9AOn?WFJ3X=^`IF&t>qlz6!v!*bE_$e$= zoT+RnEKo64uo^C)EE}A~4rXx!#W=t^I8(SX8KZb6e_<|TEpm6rNeH9ecOg-85U0MonsHsp8PYRjVSDmZGN6Qnd}J5lHOCGV95%H(uL} zXY;32N(-p1I1~va(o;pn0hDs*oGTJaR8Z1U4iyy$Zh_=bRUCM;UT1qiVr2XM%$xV# zy!Yw($Ii|Sf;HTK-+e!g&=VmftE0ti{{+le$U!CKU?=6_5^mB|T`Hxq&`F+LS4s*- zDMWBBmpEusqAufDDrsP&EVs5w8IaPo7uuEyM>4hEgm2Ra>BLVnR=U>L-b*+~PHaj! zC*vq2?{tuYqmulj;%J}9rLMV@k!_5D{Y2HK#L+KbzM@BjPP=8I|GN04K11y=(l3y@ z>%013NZqesbgd~eH@~ma z^fP>X z1l!%NNxSSW<6tHy!U9T4v4EFbwis!MDTzf*OSm>&K&%N0p{ea@>l3b)Pq;Q+0AM_j zmRp%an_Aqp)c<5FKbLK)R%m-nxa0M)```1wlhOLp4SQ4-%6 z-9R&FR{8+V;P0gwY;-g}1-pz6Yl8TWZtHQobQb8WN}7lP2_z@Xt z9=&S25sCD`*C%b)YtR^7W!v)zjr6D*G(0DU64A+QnS{)R!}{R(`K!anP8zY3c|*XH zl8+iBVxz4*ERXoFzw8{<+vowYGTB_ezhR_!m)Hp!ENrj@URg%!A)z*4i~vHrD#RBO z#I(wRKj~KHkHn$kJ3!1bai@thx_hlw*!jG$6ZdFdj5tP?D_&5k5X#f8KN)Zd^5b$n zaEQkXtvEzPVZ>)#2951n+gDPH7R!_ zunoO>_MTY{>cpI#3#$Q%%r{9i#e&d$mj*RbW|8TTX|E9uSMBnY&8mTKUL2oz=gRoS zv**kYK?kii?JzW3!yqy}uRapaaaG*a0)g&_Kwa=gU&H4Q^mGyJWRRS_dFlG4)xxRG zLigwAzsN3?b|fqtTj@?Yx7pph-fgUP8ykD}ZVnu}H+6SvePDELV07c9qZWYJJ)VsTU8G{ z-OLvMMkxrQ=GN8THMMtj|Ip9sD;sL(mJI5DqJGHo#*1Ue@i%9O#`0(-hk;+o>tm+0 zVoJap*hMh+jlI0XvYeo7S-eAZ5~djMt^`5FBO?h;rU^6+=3I;#AyH8Zo`I5$#0a9I z5nO{=csYiHYFWNrCzeIUAmM5-i;hz1LjpHGe_JHq`!G^19l;ruMH6oP4Ohx+wpp Y6mf3p#1;bb?YSoT;lz%R=paP=7sMIZ4gdfE literal 0 HcmV?d00001 diff --git a/backend/app/services/__pycache__/suggestion_engine.cpython-312.pyc b/backend/app/services/__pycache__/suggestion_engine.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..477ef9296299f2ea55d79656f39fc12e340e44f7 GIT binary patch literal 6498 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 literal 0 HcmV?d00001 diff --git a/backend/app/services/llm.py b/backend/app/services/llm.py index 005ff98..4b37419 100644 --- a/backend/app/services/llm.py +++ b/backend/app/services/llm.py @@ -11,7 +11,7 @@ # Configure Gemini genai.configure(api_key=settings.gemini_api_key) -model = genai.GenerativeModel("gemini-2.0-flash") +model = genai.GenerativeModel("gemini-2.5-flash-lite") async def call_llm(prompt: str, max_tokens: int = 1024) -> str: diff --git a/frontend/dummy.txt b/frontend/dummy.txt deleted file mode 100644 index e69de29..0000000 From 28dcb571b00c20812d9d2f1da6aa7a8d953d729a Mon Sep 17 00:00:00 2001 From: YugDalwadi Date: Sat, 28 Feb 2026 07:15:53 +0530 Subject: [PATCH 3/4] feat: fixed google-auth endpoints --- backend/API_REFERENCE.md | 1211 +++++++++++++++++ .../__pycache__/calendar.cpython-312.pyc | Bin 14295 -> 14295 bytes .../classroom_sync.cpython-312.pyc | Bin 0 -> 4089 bytes .../__pycache__/gmail_parser.cpython-312.pyc | Bin 0 -> 6546 bytes .../services/__pycache__/llm.cpython-312.pyc | Bin 1871 -> 1871 bytes 5 files changed, 1211 insertions(+) create mode 100644 backend/API_REFERENCE.md create mode 100644 backend/app/services/__pycache__/classroom_sync.cpython-312.pyc create mode 100644 backend/app/services/__pycache__/gmail_parser.cpython-312.pyc diff --git a/backend/API_REFERENCE.md b/backend/API_REFERENCE.md new file mode 100644 index 0000000..093f7b5 --- /dev/null +++ b/backend/API_REFERENCE.md @@ -0,0 +1,1211 @@ +# FocusForge API Reference + +> **Base URL:** `http://localhost:8000` +> +> **Auth:** Most endpoints require a JWT Bearer token in the `Authorization` header. +> Obtain one via `/auth/google` or `/auth/dev-login`. + +--- + +## Table of Contents + +- [Health](#health) +- [Authentication](#authentication) +- [Timetable](#timetable) +- [Calendar Events](#calendar-events) +- [Admin — Institute Calendar](#admin--institute-calendar) +- [Admin — Event Panel](#admin--event-panel) +- [Google Sync](#google-sync) +- [Career Goals](#career-goals) +- [Suggestions](#suggestions) +- [Suggestion History](#suggestion-history) +- [Focus Heatmap](#focus-heatmap) +- [App Usage](#app-usage) +- [Group Pomodoro](#group-pomodoro) +- [Pomodoro Leaderboard](#pomodoro-leaderboard) +- [Badges](#badges) +- [WebSocket — Pomodoro Timer](#websocket--pomodoro-timer) +- [Schemas Reference](#schemas-reference) + +--- + +## Health + +### `GET /health` + +Health check endpoint. No authentication required. + +**Response:** + +```json +{ + "status": "ok", + "service": "focusforge-api" +} +``` + +--- + +## Authentication + +### `POST /auth/google` + +Authenticate with a Google OAuth ID token. Returns a JWT for subsequent requests. + +**Query Parameters:** + +| Param | Type | Required | Description | +|-----------------|----------|----------|-------------------------------------| +| `id_token_str` | `string` | Yes | Google ID token from Flutter client | +| `access_token` | `string` | No | Google OAuth access token | +| `refresh_token` | `string` | No | Google OAuth refresh token | + +**Response:** [`TokenResponse`](#tokenresponse) + +```json +{ + "access_token": "eyJhbGciOi...", + "token_type": "bearer", + "user": { + "id": "uuid", + "email": "user@example.com", + "display_name": "John Doe", + "avatar_url": "https://...", + "focus_mode": "acads", + "role": "student", + "created_at": "2026-02-28T12:00:00" + } +} +``` + +--- + +### `POST /auth/dev-login` + +**DEV ONLY** — Bypass Google OAuth for testing. Only available when `APP_ENV=development` or `APP_ENV=testing`. + +**Query Parameters:** + +| Param | Type | Default | Description | +|----------------|----------|--------------------------|--------------------| +| `email` | `string` | `testuser@example.com` | User email | +| `display_name` | `string` | `Test User` | Display name | +| `role` | `string` | `student` | `student` or `admin` | + +**Response:** [`TokenResponse`](#tokenresponse) + +--- + +### `GET /auth/me` + +Get authenticated user's profile. + +**Headers:** `Authorization: Bearer ` + +**Response:** [`UserOut`](#userout) + +```json +{ + "id": "uuid", + "email": "user@example.com", + "display_name": "John Doe", + "avatar_url": "https://...", + "focus_mode": "acads", + "role": "student", + "created_at": "2026-02-28T12:00:00" +} +``` + +--- + +### `PATCH /auth/me/focus-mode` + +Switch between **Acads Mode** and **Clubs & Projects Mode**. + +**Headers:** `Authorization: Bearer ` + +**Request Body:** [`UserFocusModeUpdate`](#userfocusmodeupdate) + +```json +{ + "focus_mode": "acads" +} +``` + +| Field | Type | Allowed Values | +|--------------|----------|----------------------| +| `focus_mode` | `string` | `"acads"`, `"clubs"` | + +**Response:** [`UserOut`](#userout) + +--- + +## Timetable + +### `GET /timetable/{user_id}` + +Get all timetable slots for a user. + +**Path Parameters:** `user_id` (UUID) + +**Headers:** `Authorization: Bearer ` + +**Response:** `list[`[`TimetableSlotOut`](#timetableslotout)`]` + +```json +[ + { + "id": "uuid", + "user_id": "uuid", + "day_of_week": 0, + "start_time": "09:00:00", + "end_time": "10:00:00", + "title": "Data Structures", + "location": "Room 301", + "created_at": "2026-02-28T12:00:00" + } +] +``` + +--- + +### `POST /timetable` + +Create a new timetable slot. + +**Headers:** `Authorization: Bearer ` + +**Request Body:** [`TimetableSlotCreate`](#timetableslotcreate) + +```json +{ + "day_of_week": 0, + "start_time": "09:00:00", + "end_time": "10:00:00", + "title": "Data Structures", + "location": "Room 301" +} +``` + +**Response:** `201` [`TimetableSlotOut`](#timetableslotout) + +--- + +### `DELETE /timetable/{slot_id}` + +Delete a timetable slot. Only the owner can delete. + +**Path Parameters:** `slot_id` (UUID) + +**Headers:** `Authorization: Bearer ` + +**Response:** `204 No Content` + +--- + +## Calendar Events + +### `GET /calendar/{user_id}` + +Unified calendar view — returns user-specific events + global events (holidays, fests). + +**Path Parameters:** `user_id` (UUID) + +**Query Parameters:** + +| Param | Type | Required | Description | +|--------------|----------|----------|--------------------------------------------------------------------| +| `start_date` | `date` | No | Filter events on or after this date (`YYYY-MM-DD`) | +| `end_date` | `date` | No | Filter events on or before this date | +| `event_type` | `string` | No | Filter by type: `assignment`, `institute_holiday`, `fest`, `club_event`, `gmail_event` | + +**Headers:** `Authorization: Bearer ` + +**Response:** `list[`[`CalendarEventOut`](#calendareventout)`]` + +```json +[ + { + "id": "uuid", + "user_id": "uuid", + "event_type": "assignment", + "title": "ML Assignment 3", + "description": "Submit on Moodle", + "event_date": "2026-03-05", + "event_time": "23:59:00", + "end_date": null, + "location": null, + "source": "classroom", + "moderation_status": "approved", + "created_at": "2026-02-28T12:00:00" + } +] +``` + +--- + +### `POST /calendar/events` + +Create a personal calendar event. + +**Headers:** `Authorization: Bearer ` + +**Request Body:** [`CalendarEventCreate`](#calendareventcreate) + +```json +{ + "event_type": "club_event", + "title": "Hackathon Prep", + "description": "Team meeting", + "event_date": "2026-03-10", + "event_time": "18:00:00", + "end_date": null, + "location": "Lab 5", + "source": null, + "source_id": null, + "metadata_json": null +} +``` + +**Response:** `201` [`CalendarEventOut`](#calendareventout) + +--- + +## Admin — Institute Calendar + +### `POST /admin/institute-calendar` + +Upload a CSV of institute holidays/fests. **Admin only.** + +**Headers:** `Authorization: Bearer ` (admin role) + +**Body:** `multipart/form-data` with a `.csv` file. + +**CSV Columns:** + +| Column | Required | Description | +|---------------|----------|------------------------------------------| +| `date` | Yes | Event date (`YYYY-MM-DD`) | +| `title` | Yes | Event title | +| `type` | No | `institute_holiday` or `fest` (default: `institute_holiday`) | +| `description` | No | Optional description | + +**Response:** `201` + +```json +{ + "inserted": 15 +} +``` + +--- + +## Admin — Event Panel + +### `POST /admin/events` + +Club secretary/coordinator posts a club event. Starts as `pending` moderation. **Admin only.** + +**Headers:** `Authorization: Bearer ` (admin role) + +**Request Body:** [`AdminEventCreate`](#admineventcreate) + +```json +{ + "title": "Robotics Workshop", + "description": "Intro to Arduino", + "event_date": "2026-03-15", + "event_time": "14:00:00", + "end_date": "2026-03-15", + "location": "Auditorium" +} +``` + +**Response:** `201` [`CalendarEventOut`](#calendareventout) + +--- + +### `GET /admin/events` + +List admin-created events with optional moderation filter. **Admin only.** + +**Query Parameters:** + +| Param | Type | Required | Description | +|---------------------|----------|----------|---------------------------------------| +| `moderation_status` | `string` | No | `pending`, `approved`, or `rejected` | + +**Headers:** `Authorization: Bearer ` (admin role) + +**Response:** `list[`[`CalendarEventOut`](#calendareventout)`]` + +--- + +### `PATCH /admin/events/{event_id}/approve` + +Approve a pending event. **Admin only.** + +**Path Parameters:** `event_id` (UUID) + +**Headers:** `Authorization: Bearer ` (admin role) + +**Response:** [`CalendarEventOut`](#calendareventout) + +--- + +### `DELETE /admin/events/{event_id}` + +Delete an admin event. **Admin only.** + +**Path Parameters:** `event_id` (UUID) + +**Headers:** `Authorization: Bearer ` (admin role) + +**Response:** `204 No Content` + +--- + +## Google Sync + +### `POST /calendar/sync/classroom` + +Pull assignment deadlines from Google Classroom. Requires Google OAuth tokens. + +**Headers:** `Authorization: Bearer ` + +**Response:** + +```json +{ + "synced_assignments": 12 +} +``` + +--- + +### `POST /calendar/sync/gmail` + +Parse Gmail for club activity emails and extract events via LLM. Requires Google OAuth tokens. + +**Headers:** `Authorization: Bearer ` + +**Response:** + +```json +{ + "parsed_events": 5 +} +``` + +--- + +## Career Goals + +### `GET /user/{user_id}/goals` + +Get all career goals for a user. + +**Path Parameters:** `user_id` (UUID) + +**Headers:** `Authorization: Bearer ` + +**Response:** `list[`[`CareerGoalOut`](#careergoalout)`]` + +```json +[ + { + "id": "uuid", + "user_id": "uuid", + "title": "GATE CS 2027", + "description": "Target AIR < 100", + "created_at": "2026-02-28T12:00:00" + } +] +``` + +--- + +### `PUT /user/{user_id}/goals` + +Replace all career goals for a user (deletes existing, inserts new). + +**Path Parameters:** `user_id` (UUID) + +**Headers:** `Authorization: Bearer ` + +**Request Body:** `list[`[`CareerGoalCreate`](#careergoalcreate)`]` + +```json +[ + { + "title": "GATE CS 2027", + "description": "Target AIR < 100" + }, + { + "title": "GSoC 2026", + "description": null + } +] +``` + +**Response:** `list[`[`CareerGoalOut`](#careergoalout)`]` + +--- + +## Suggestions + +### `GET /suggestions/{user_id}` + +LLM-scored suggestions based on focus mode, career goals, upcoming calendar events, and workload. + +**Path Parameters:** `user_id` (UUID) + +**Query Parameters:** + +| Param | Type | Default | Description | +|--------|----------|----------|-------------------------------| +| `mode` | `string` | `acads` | `"acads"` or `"clubs"` | + +**Headers:** `Authorization: Bearer ` + +**Response:** [`SuggestionResponse`](#suggestionresponse) + +```json +{ + "mode": "acads", + "suggestions": [ + { + "title": "Revise DBMS Normalization", + "description": "Mid-sem in 3 days — cover 3NF and BCNF", + "priority": 0.95, + "source": "deadline" + }, + { + "title": "LeetCode — Graph problems", + "description": "Aligned with GATE CS goal. Try BFS/DFS set.", + "priority": 0.8, + "source": "goal" + } + ], + "quote": "Focus is the art of knowing what to ignore.", + "workload_score": 7.2 +} +``` + +--- + +## Suggestion History + +### `GET /suggestions/{user_id}/history` + +Get past suggestion batches (most recent 50). + +**Path Parameters:** `user_id` (UUID) + +**Headers:** `Authorization: Bearer ` + +**Response:** `list[`[`SuggestionHistoryOut`](#suggestionhistoryout)`]` + +```json +[ + { + "id": "uuid", + "mode": "acads", + "suggestions_json": [ + { + "title": "Revise DBMS", + "description": "...", + "priority": 0.95, + "source": "deadline" + } + ], + "quote": "Focus is the art of knowing what to ignore.", + "is_completed": false, + "is_dismissed": false, + "created_at": "2026-02-28T12:00:00" + } +] +``` + +--- + +### `PATCH /suggestions/history/{suggestion_id}` + +Update completion/dismissal status of a suggestion. + +**Path Parameters:** `suggestion_id` (UUID) + +**Headers:** `Authorization: Bearer ` + +**Request Body:** [`SuggestionStatusUpdate`](#suggestionstatusupdate) + +```json +{ + "is_completed": true, + "is_dismissed": false +} +``` + +**Response:** [`SuggestionHistoryOut`](#suggestionhistoryout) + +--- + +## Focus Heatmap + +### `GET /focus-log/{user_id}/heatmap` + +90-day GitHub-style focus heatmap — daily Pomodoro + active-session minutes. + +**Path Parameters:** `user_id` (UUID) + +**Headers:** `Authorization: Bearer ` + +**Response:** [`HeatmapResponse`](#heatmapresponse) + +```json +{ + "user_id": "uuid", + "entries": [ + { + "date": "2026-02-28", + "focus_minutes": 120, + "mode": "acads" + }, + { + "date": "2026-02-27", + "focus_minutes": 45, + "mode": "clubs" + } + ] +} +``` + +--- + +## App Usage + +### `POST /usage` + +Client posts app usage data (called every 30 minutes by the Flutter client). + +**Headers:** `Authorization: Bearer ` + +**Request Body:** [`AppUsageBatchCreate`](#appusagebatchcreate) + +```json +{ + "entries": [ + { + "package_name": "com.leetcode", + "duration_ms": 1800000, + "date": "2026-02-28" + }, + { + "package_name": "com.instagram.android", + "duration_ms": 600000, + "date": "2026-02-28" + } + ] +} +``` + +**Response:** `201` + +```json +{ + "inserted": 2 +} +``` + +--- + +### `GET /usage/{user_id}/rolling-average` + +Rolling average per category vs institute average, plus percentile rank. + +**Path Parameters:** `user_id` (UUID) + +**Query Parameters:** + +| Param | Type | Default | Description | +|---------------|-------|---------|--------------------------| +| `period_days` | `int` | `7` | Rolling window in days | + +**Headers:** `Authorization: Bearer ` + +**Response:** [`RollingAverageResponse`](#rollingaverageresponse) + +```json +{ + "user_id": "uuid", + "period_days": 7, + "stats": [ + { + "category": "productive", + "user_avg_ms": 3600000.0, + "institute_avg_ms": 2400000.0, + "user_percentile": 0.75 + }, + { + "category": "distracting", + "user_avg_ms": 900000.0, + "institute_avg_ms": 1500000.0, + "user_percentile": 0.30 + } + ] +} +``` + +--- + +### `GET /usage/{user_id}/should-nudge` + +Check if user's productive usage is below the 50th percentile (triggers push notification). + +**Path Parameters:** `user_id` (UUID) + +**Headers:** `Authorization: Bearer ` + +**Response:** + +```json +{ + "should_nudge": true, + "percentile": 35.2 +} +``` + +--- + +### `GET /usage/categories` + +List all known app-to-category mappings. + +**Headers:** `Authorization: Bearer ` + +**Response:** + +```json +[ + { + "package_name": "com.leetcode", + "category": "productive", + "display_name": "LeetCode" + }, + { + "package_name": "com.instagram.android", + "category": "distracting", + "display_name": "Instagram" + } +] +``` + +--- + +## Group Pomodoro + +### `POST /pomodoro/sessions` + +Create a new group Pomodoro session. The creator auto-joins. + +**Headers:** `Authorization: Bearer ` + +**Request Body:** [`PomodoroSessionCreate`](#pomodorosessioncreate) + +```json +{ + "focus_duration_min": 25, + "break_duration_min": 5, + "total_intervals": 4 +} +``` + +**Response:** `201` [`PomodoroSessionOut`](#pomodorosessionout) + +```json +{ + "id": "uuid", + "invite_code": "A1B2C3D4", + "created_by": "uuid", + "focus_duration_min": 25, + "break_duration_min": 5, + "total_intervals": 4, + "status": "waiting", + "current_interval": 0, + "started_at": null, + "ended_at": null, + "created_at": "2026-02-28T12:00:00", + "members": [ + { + "id": "uuid", + "user_id": "uuid", + "is_active": true, + "is_paused": false, + "focus_seconds": 0, + "xp_earned": 0, + "joined_at": "2026-02-28T12:00:00" + } + ] +} +``` + +--- + +### `POST /pomodoro/sessions/join` + +Join an existing session via invite code. Max 8 members. Session must be in `waiting` status. + +**Headers:** `Authorization: Bearer ` + +**Request Body:** [`SessionJoin`](#sessionjoin) + +```json +{ + "invite_code": "A1B2C3D4" +} +``` + +**Response:** [`PomodoroSessionOut`](#pomodorosessionout) + +--- + +### `GET /pomodoro/sessions/{session_id}` + +Get session details including all members. + +**Path Parameters:** `session_id` (UUID) + +**Headers:** `Authorization: Bearer ` + +**Response:** [`PomodoroSessionOut`](#pomodorosessionout) + +--- + +## Pomodoro Leaderboard + +### `GET /pomodoro/sessions/{session_id}/leaderboard` + +Weekly XP leaderboard for a session group. + +**Path Parameters:** `session_id` (UUID) + +**Headers:** `Authorization: Bearer ` + +**Response:** [`LeaderboardResponse`](#leaderboardresponse) + +```json +{ + "session_id": "uuid", + "entries": [ + { + "user_id": "uuid", + "display_name": "Alice", + "total_xp": 400, + "rank": 1 + }, + { + "user_id": "uuid", + "display_name": "Bob", + "total_xp": 300, + "rank": 2 + } + ] +} +``` + +--- + +## Badges + +### `GET /pomodoro/badges/{user_id}` + +Get all badges earned by a user. + +**Path Parameters:** `user_id` (UUID) + +**Headers:** `Authorization: Bearer ` + +**Response:** `list[`[`BadgeOut`](#badgeout)`]` + +```json +[ + { + "id": "uuid", + "user_id": "uuid", + "badge_type": "streak_7", + "awarded_at": "2026-02-28T12:00:00" + } +] +``` + +**Badge Types:** `top_focus`, `streak_7`, `streak_30` + +--- + +## WebSocket — Pomodoro Timer + +### `WS /ws/pomodoro/{session_id}?token=` + +Real-time timer sync for group Pomodoro sessions. + +**Connection:** `ws://localhost:8000/ws/pomodoro/{session_id}?token=` + +#### Client → Server Messages + +| Type | Description | Payload | +|-------------|--------------------------------|--------------------------| +| `start` | Start the session (creator only) | `{"type": "start"}` | +| `heartbeat` | Prove user is still focused | `{"type": "heartbeat"}` | +| `pause` | Voluntary pause (private) | `{"type": "pause"}` | +| `resume` | Resume after pause | `{"type": "resume"}` | + +#### Server → Client Messages + +| Type | Description | Example Data | +|-----------------|---------------------------------|--------------------------------------------------------------------------| +| `state_change` | Session phase changed | `{"status": "focus", "interval": 1}` | +| `tick` | Timer tick (every second) | `{"status": "focus", "interval": 1, "remaining_seconds": 1499}` | +| `member_update` | Member connected/disconnected | `{"user_id": "...", "action": "connected", "member_count": 3}` | +| `nudge` | Focus nudge (private to user) | `{"message": "You seem to have lost focus. Stay strong!"}` | +| `session_end` | Session completed | `{"members": [{"user_id": "...", "xp_earned": 400, "focus_seconds": 6000}]}` | + +--- + +## Schemas Reference + +### UserOut + +```json +{ + "id": "uuid", + "email": "string", + "display_name": "string", + "avatar_url": "string | null", + "focus_mode": "string", + "role": "string", + "created_at": "datetime" +} +``` + +### UserFocusModeUpdate + +```json +{ + "focus_mode": "string" // "acads" | "clubs" +} +``` + +### TokenResponse + +```json +{ + "access_token": "string", + "token_type": "bearer", + "user": "UserOut" +} +``` + +--- + +### TimetableSlotCreate + +```json +{ + "day_of_week": "int", // 0=Monday .. 6=Sunday + "start_time": "time", // "HH:MM:SS" + "end_time": "time", // "HH:MM:SS" + "title": "string", + "location": "string | null" +} +``` + +### TimetableSlotOut + +```json +{ + "id": "uuid", + "user_id": "uuid", + "day_of_week": "int", + "start_time": "time", + "end_time": "time", + "title": "string", + "location": "string | null", + "created_at": "datetime" +} +``` + +--- + +### CalendarEventCreate + +```json +{ + "event_type": "string", // "assignment" | "institute_holiday" | "fest" | "club_event" + "title": "string", + "description": "string | null", + "event_date": "date", // "YYYY-MM-DD" + "event_time": "time | null", // "HH:MM:SS" + "end_date": "date | null", + "location": "string | null", + "source": "string | null", + "source_id": "string | null", + "metadata_json": "dict | null" +} +``` + +### CalendarEventOut + +```json +{ + "id": "uuid", + "user_id": "uuid | null", + "event_type": "string", + "title": "string", + "description": "string | null", + "event_date": "date", + "event_time": "time | null", + "end_date": "date | null", + "location": "string | null", + "source": "string | null", + "moderation_status": "string", + "created_at": "datetime" +} +``` + +### AdminEventCreate + +```json +{ + "title": "string", + "description": "string | null", + "event_date": "date", + "event_time": "time | null", + "end_date": "date | null", + "location": "string | null" +} +``` + +--- + +### CareerGoalCreate + +```json +{ + "title": "string", + "description": "string | null" +} +``` + +### CareerGoalOut + +```json +{ + "id": "uuid", + "user_id": "uuid", + "title": "string", + "description": "string | null", + "created_at": "datetime" +} +``` + +--- + +### SuggestionItem + +```json +{ + "title": "string", + "description": "string", + "priority": "float", // 0.0 — 1.0 + "source": "string" // "deadline" | "habit" | "goal" +} +``` + +### SuggestionResponse + +```json +{ + "mode": "string", + "suggestions": ["SuggestionItem"], + "quote": "string", + "workload_score": "float" // 0.0 — 10.0 +} +``` + +### SuggestionHistoryOut + +```json +{ + "id": "uuid", + "mode": "string", + "suggestions_json": "list | dict", + "quote": "string | null", + "is_completed": "bool", + "is_dismissed": "bool", + "created_at": "datetime" +} +``` + +### SuggestionStatusUpdate + +```json +{ + "is_completed": "bool | null", + "is_dismissed": "bool | null" +} +``` + +--- + +### HeatmapEntry + +```json +{ + "date": "string", // ISO date "YYYY-MM-DD" + "focus_minutes": "int", + "mode": "string" +} +``` + +### HeatmapResponse + +```json +{ + "user_id": "uuid", + "entries": ["HeatmapEntry"] +} +``` + +--- + +### AppUsageEntry + +```json +{ + "package_name": "string", + "duration_ms": "int", + "date": "date" // "YYYY-MM-DD" +} +``` + +### AppUsageBatchCreate + +```json +{ + "entries": ["AppUsageEntry"] +} +``` + +### AppUsageStats + +```json +{ + "category": "string", + "user_avg_ms": "float", + "institute_avg_ms": "float", + "user_percentile": "float | null" +} +``` + +### RollingAverageResponse + +```json +{ + "user_id": "uuid", + "period_days": "int", + "stats": ["AppUsageStats"] +} +``` + +--- + +### PomodoroSessionCreate + +```json +{ + "focus_duration_min": 25, // default 25 + "break_duration_min": 5, // default 5 + "total_intervals": 4 // default 4 +} +``` + +### PomodoroSessionOut + +```json +{ + "id": "uuid", + "invite_code": "string", + "created_by": "uuid", + "focus_duration_min": "int", + "break_duration_min": "int", + "total_intervals": "int", + "status": "string", // "waiting" | "focus" | "break" | "completed" + "current_interval": "int", + "started_at": "datetime | null", + "ended_at": "datetime | null", + "created_at": "datetime", + "members": ["SessionMemberOut"] +} +``` + +### SessionJoin + +```json +{ + "invite_code": "string" +} +``` + +### SessionMemberOut + +```json +{ + "id": "uuid", + "user_id": "uuid", + "is_active": "bool", + "is_paused": "bool", + "focus_seconds": "int", + "xp_earned": "int", + "joined_at": "datetime" +} +``` + +### LeaderboardEntry + +```json +{ + "user_id": "uuid", + "display_name": "string", + "total_xp": "int", + "rank": "int" +} +``` + +### LeaderboardResponse + +```json +{ + "session_id": "uuid", + "entries": ["LeaderboardEntry"] +} +``` + +### BadgeOut + +```json +{ + "id": "uuid", + "user_id": "uuid", + "badge_type": "string", // "top_focus" | "streak_7" | "streak_30" + "awarded_at": "datetime" +} +``` + +### WsMessage + +```json +{ + "type": "string", // "tick" | "state_change" | "member_update" | "session_end" | "nudge" + "data": "dict" +} +``` diff --git a/backend/app/routers/__pycache__/calendar.cpython-312.pyc b/backend/app/routers/__pycache__/calendar.cpython-312.pyc index 7635c3ea51012da1173998d39fe40e5dac4f8566..649510c53dc2d60ed667c9cb19d5c7e8b22fc4cf 100644 GIT binary patch delta 19 Zcmcbfe?6b;G%qg~0}!lm-^g{=8~{Xi1~dQw delta 19 Zcmcbfe?6b;G%qg~0}z~*+Q@a*8~{Xf1}p#o diff --git a/backend/app/services/__pycache__/classroom_sync.cpython-312.pyc b/backend/app/services/__pycache__/classroom_sync.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b90dffdc3a01b45dc8835bd069414cf77e35a928 GIT binary patch literal 4089 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 delta 18 YcmX@lcb<>)G%qg~0}$wLP04@Im=Kufz From e58041b4870f4d4b3385e16cedc83f25ef1a5fdb Mon Sep 17 00:00:00 2001 From: Tanisha Ray Date: Sat, 28 Feb 2026 09:27:15 +0530 Subject: [PATCH 4/4] added backend documentation --- backend documentation.md | 519 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 519 insertions(+) create mode 100644 backend documentation.md diff --git a/backend documentation.md b/backend documentation.md new file mode 100644 index 0000000..4520e57 --- /dev/null +++ b/backend documentation.md @@ -0,0 +1,519 @@ +# 🏗 Complete Backend Architecture + +### Project Layout +``` +backend/ +├── main.py ← Entry point (runs app/main.py) +└── app/ + ├── main.py ← FastAPI app, lifespan, CORS, router registration + ├── config.py ← Pydantic settings from .env + ├── database.py ← AsyncEngine, AsyncSessionLocal, get_db() + ├── logging_config.py ← Logging setup + ├── models/ ← SQLAlchemy ORM tables + ├── schemas/ ← Pydantic request/response shapes + ├── routers/ ← FastAPI route handlers + └── services/ ← Business logic, LLM, Google APIs, WebSocket +``` + +--- + +## 🔄 Feature-by-Feature Flow: Flutter → Backend + +--- + +### 1️⃣ Auth Flow (FR-01) + +``` +Flutter (google_sign_in) + │ + ├─ Gets: id_token, access_token, refresh_token from Google + │ + ▼ +POST /auth/google + Body: ?id_token=...&access_token=...&refresh_token=... + │ + ├─ Backend verifies id_token with Google's public key + ├─ Finds or creates User in DB (stores tokens) + ├─ Issues own JWT (24h expiry) + │ + ▼ +Response → Flutter stores JWT in secure storage + │ + └─ All subsequent requests: Authorization: Bearer +``` + +**Request (query params):** +``` +POST /auth/google?id_token=eyJ...&access_token=ya29...&refresh_token=1//0g... +``` + +**Response JSON:** +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "token_type": "bearer", + "user": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "email": "tanisha@college.edu", + "display_name": "Tanisha Ray", + "avatar_url": "https://lh3.googleusercontent.com/...", + "focus_mode": "acads", + "role": "student", + "created_at": "2026-02-28T10:00:00Z" + } +} +``` + +**Switch Focus Mode:** +``` +PATCH /auth/me/focus-mode +Authorization: Bearer +Body: { "focus_mode": "clubs" } +``` + +--- + +### 2️⃣ Suggestion Dashboard Flow (FR-03, FR-04, FR-14, FR-15) + +``` +Flutter opens app + │ + ├─ GET /user/{user_id}/goals ← load career goals + ├─ GET /suggestions/{user_id}?mode=acads ← main dashboard call + └─ GET /suggestions/{user_id}/heatmap ← 90-day grid +``` + +**GET /suggestions/{user_id}?mode=acads** + +Backend flow: +``` +1. Fetch user's career goals from DB +2. Fetch upcoming 48h calendar events (user + global) +3. Count assignments in next 7 days → workload score +4. Assemble prompt → call Gemini Flash +5. Parse LLM JSON → Pydantic validate +6. Cache in suggestion_history table +7. Return response +``` + +**Response JSON:** +```json +{ + "mode": "acads", + "suggestions": [ + { + "title": "Complete OS Assignment 3", + "description": "Due tomorrow at 11:59 PM. High priority based on deadline urgency.", + "priority": 0.95, + "source": "deadline" + }, + { + "title": "Revise DBMS notes", + "description": "Mid-sem exam in 3 days — focus on normalization and indexing.", + "priority": 0.78, + "source": "habit" + }, + { + "title": "Work on ML project", + "description": "Aligned with your career goal: ML researcher. Spend 1 hour today.", + "priority": 0.65, + "source": "goal" + } + ], + "quote": "Every expert was once a beginner. Your future ML career starts with today's focus.", + "workload_score": 0.82 +} +``` + +**PUT /user/{user_id}/goals** — set career goals: +```json +[ + { "title": "Crack placement at Google", "category": "career" }, + { "title": "Become an ML researcher", "category": "career" } +] +``` + +**GET /suggestions/{user_id}/heatmap** — 90-day data: +```json +{ + "user_id": "550e8400-...", + "entries": [ + { "date": "2026-02-28", "focus_minutes": 75, "mode": "acads" }, + { "date": "2026-02-27", "focus_minutes": 50, "mode": "clubs" }, + { "date": "2026-02-26", "focus_minutes": 0, "mode": "acads" } + ] +} +``` + +**PATCH /suggestions/history/{id}** — mark as done/dismissed: +```json +{ "is_completed": true, "is_dismissed": false } +``` + +--- + +### 3️⃣ Calendar Flow (FR-05 → FR-08, FR-16, FR-25, FR-26) + +``` +Flutter Calendar Screen + │ + ├─ GET /calendar/{user_id}?start_date=2026-03-01&end_date=2026-03-31 + │ ← Returns merged: timetable + holidays + assignments + club events + │ + ├─ GET /timetable/{user_id} ← Load recurring weekly slots + ├─ POST /timetable ← Add a new slot + ├─ DELETE /timetable/{slot_id} ← Remove a slot + │ + ├─ POST /calendar/sync/classroom ← Pull Classroom deadlines + └─ POST /calendar/sync/gmail ← Parse Gmail club events via LLM +``` + +**POST /timetable — Request:** +```json +{ + "day_of_week": 1, + "start_time": "09:00:00", + "end_time": "10:00:00", + "title": "Operating Systems", + "location": "Room 301" +} +``` + +**GET /calendar/{user_id} — Response:** +```json +[ + { + "id": "abc123...", + "user_id": "550e84...", + "event_type": "assignment", + "title": "OS Assignment 3", + "description": "Submit on Classroom", + "event_date": "2026-03-01", + "event_time": "23:59:00", + "end_date": null, + "location": null, + "source": "classroom", + "moderation_status": "approved", + "created_at": "2026-02-28T10:00:00Z" + }, + { + "id": "def456...", + "user_id": null, + "event_type": "institute_holiday", + "title": "Holi", + "description": null, + "event_date": "2026-03-14", + "event_time": null, + "end_date": null, + "location": null, + "source": "admin", + "moderation_status": "approved", + "created_at": "2026-02-20T08:00:00Z" + }, + { + "id": "ghi789...", + "user_id": null, + "event_type": "club_event", + "title": "Robotics Club Workshop", + "event_date": "2026-03-05", + "event_time": "14:00:00", + "location": "Lab 2", + "source": "gmail", + "moderation_status": "approved", + "created_at": "2026-02-28T09:00:00Z" + } +] +``` + +**Admin CSV Upload — POST /admin/institute-calendar:** +``` +CSV format (multipart/form-data): +date,title,type,description +2026-03-14,Holi,institute_holiday,National holiday +2026-04-10,TechFest,fest,Annual college tech festival +``` +Response: `{ "inserted": 2 }` + +**Admin Event Panel — POST /admin/events:** +```json +{ + "title": "Robotics Club Workshop", + "description": "Hands-on session on ROS2", + "event_date": "2026-03-05", + "event_time": "14:00:00", + "location": "Lab 2" +} +``` +Response: Same as `CalendarEventOut` but `moderation_status: "pending"` + +**Admin Approve — PATCH /admin/events/{event_id}/approve:** +No body needed. Sets `moderation_status → "approved"`. + +--- + +### 4️⃣ Usage Tracker Flow (FR-09, FR-10, FR-18, FR-19) + +``` +Flutter WorkManager (every 30 min) + │ + ├─ Kotlin UsageStatsPlugin reads UsageStatsManager + ├─ Dart platform channel returns [{package_name, duration_ms, date}] + ├─ POST /usage ← batch upload + │ +Flutter Usage Screen + ├─ GET /usage/{user_id}/rolling-average?period_days=7 + └─ GET /usage/{user_id}/should-nudge ← check if notification needed +``` + +**POST /usage — Request:** +```json +{ + "entries": [ + { "package_name": "com.google.android.youtube", "duration_ms": 3600000, "date": "2026-02-28" }, + { "package_name": "com.google.android.apps.docs", "duration_ms": 1800000, "date": "2026-02-28" }, + { "package_name": "com.instagram.android", "duration_ms": 5400000, "date": "2026-02-28" } + ] +} +``` +Backend maps packages to categories via `app_category_mappings` table. +Response: `{ "inserted": 3 }` + +**GET /usage/{user_id}/rolling-average — Response:** +```json +{ + "user_id": "550e8400-...", + "period_days": 7, + "stats": [ + { + "category": "productive", + "user_avg_ms": 5400000, + "institute_avg_ms": 7200000, + "user_percentile": 0.38 + }, + { + "category": "distraction", + "user_avg_ms": 9000000, + "institute_avg_ms": 6000000, + "user_percentile": 0.72 + }, + { + "category": "neutral", + "user_avg_ms": 3600000, + "institute_avg_ms": 3600000, + "user_percentile": 0.50 + } + ] +} +``` + +**GET /usage/{user_id}/should-nudge — Response:** +```json +{ "should_nudge": true, "percentile": 0.38 } +``` +Flutter fires a **local push notification** if `should_nudge == true`. + +--- + +### 5️⃣ Group Pomodoro Flow (FR-11 → FR-13, FR-20 → FR-23) + +``` +User A creates session: + POST /pomodoro/sessions → gets invite_code: "FOCUS8XY" + +User B joins: + POST /pomodoro/sessions/join body: { "invite_code": "FOCUS8XY" } + +Both connect to WebSocket: + WS /ws/pomodoro/{session_id}?token= + +User A starts: + Client → server: { "type": "start" } + +Server runs timer loop: + Server → all: tick every second + +Session ends: + Server → all: session_end with XP results + Server awards badges via _evaluate_badges() + +Get leaderboard: + GET /pomodoro/sessions/{id}/leaderboard + +Get badges: + GET /pomodoro/badges/{user_id} +``` + +**POST /pomodoro/sessions — Request:** +```json +{ + "focus_duration_min": 25, + "break_duration_min": 5, + "total_intervals": 4 +} +``` + +**Response:** +```json +{ + "id": "aaaa-bbbb-...", + "invite_code": "FOCUS8XY", + "created_by": "550e8400-...", + "focus_duration_min": 25, + "break_duration_min": 5, + "total_intervals": 4, + "status": "waiting", + "current_interval": 0, + "started_at": null, + "ended_at": null, + "created_at": "2026-02-28T11:00:00Z", + "members": [ + { + "id": "...", + "user_id": "550e...", + "is_active": true, + "is_paused": false, + "focus_seconds": 0, + "xp_earned": 0, + "joined_at": "2026-02-28T11:00:00Z" + } + ] +} +``` + +**WebSocket Messages (Client → Server):** +```json +{ "type": "heartbeat" } +{ "type": "start" } +{ "type": "pause" } +{ "type": "resume" } +``` + +**WebSocket Messages (Server → Client):** +```json +// Every second during focus/break +{ "type": "tick", "data": { "status": "focus", "interval": 1, "remaining_seconds": 1487 } } + +// Phase change +{ "type": "state_change", "data": { "status": "break", "interval": 1 } } + +// Member connect/disconnect +{ "type": "member_update", "data": { "user_id": "abc...", "action": "connected", "member_count": 2 } } + +// Private nudge (only sent to the stale user) +{ "type": "nudge", "data": { "message": "You seem to have lost focus. Stay strong!" } } + +// Session complete — broadcast to all +{ + "type": "session_end", + "data": { + "members": [ + { "user_id": "abc...", "xp_earned": 400, "focus_seconds": 6000 }, + { "user_id": "def...", "xp_earned": 300, "focus_seconds": 4500 } + ] + } +} +``` + +**GET /pomodoro/sessions/{id}/leaderboard — Response:** +```json +{ + "session_id": "aaaa-bbbb-...", + "entries": [ + { "user_id": "abc...", "display_name": "Yug Dalwadi", "total_xp": 1200, "rank": 1 }, + { "user_id": "def...", "display_name": "Tanisha Ray", "total_xp": 900, "rank": 2 } + ] +} +``` + +**GET /pomodoro/badges/{user_id} — Response:** +```json +[ + { "id": "...", "user_id": "...", "badge_type": "streak_7", "awarded_at": "2026-02-28T..." }, + { "id": "...", "user_id": "...", "badge_type": "top_focus", "awarded_at": "2026-02-28T..." } +] +``` +Badge types: `"streak_7"` | `"streak_30"` | `"top_focus"` + +--- + +## 📋 All Schemas at a Glance + +| Schema | File | Fields | +|--------|------|--------| +| `TokenResponse` | user.py | `access_token`, `token_type`, `user: UserOut` | +| `UserOut` | user.py | `id`, `email`, `display_name`, `avatar_url`, `focus_mode`, `role`, `created_at` | +| `UserFocusModeUpdate` | user.py | `focus_mode` | +| `CareerGoalCreate` | career_goal.py | `title`, `category` | +| `CareerGoalOut` | career_goal.py | `id`, `user_id`, `title`, `category` | +| `TimetableSlotCreate` | calendar.py | `day_of_week`, `start_time`, `end_time`, `title`, `location` | +| `TimetableSlotOut` | calendar.py | + `id`, `user_id`, `created_at` | +| `CalendarEventCreate` | calendar.py | `event_type`, `title`, `description`, `event_date`, `event_time`, `end_date`, `location`, `source`, `source_id`, `metadata_json` | +| `CalendarEventOut` | calendar.py | + `id`, `user_id`, `moderation_status`, `created_at` | +| `AdminEventCreate` | calendar.py | `title`, `description`, `event_date`, `event_time`, `end_date`, `location` | +| `SuggestionItem` | suggestion.py | `title`, `description`, `priority`, `source` | +| `SuggestionResponse` | suggestion.py | `mode`, `suggestions[]`, `quote`, `workload_score` | +| `SuggestionHistoryOut` | suggestion.py | `id`, `mode`, `suggestions_json`, `quote`, `is_completed`, `is_dismissed`, `created_at` | +| `SuggestionStatusUpdate` | suggestion.py | `is_completed`, `is_dismissed` | +| `HeatmapEntry` | suggestion.py | `date`, `focus_minutes`, `mode` | +| `HeatmapResponse` | suggestion.py | `user_id`, `entries[]` | +| `AppUsageEntry` | usage.py | `package_name`, `duration_ms`, `date` | +| `AppUsageBatchCreate` | usage.py | `entries[]` | +| `AppUsageStats` | usage.py | `category`, `user_avg_ms`, `institute_avg_ms`, `user_percentile` | +| `RollingAverageResponse` | usage.py | `user_id`, `period_days`, `stats[]` | +| `PomodoroSessionCreate` | pomodoro.py | `focus_duration_min`, `break_duration_min`, `total_intervals` | +| `PomodoroSessionOut` | pomodoro.py | `id`, `invite_code`, `created_by`, timings, `status`, `current_interval`, `members[]` | +| `SessionJoin` | pomodoro.py | `invite_code` | +| `SessionMemberOut` | pomodoro.py | `id`, `user_id`, `is_active`, `is_paused`, `focus_seconds`, `xp_earned`, `joined_at` | +| `LeaderboardEntry` | pomodoro.py | `user_id`, `display_name`, `total_xp`, `rank` | +| `LeaderboardResponse` | pomodoro.py | `session_id`, `entries[]` | +| `BadgeOut` | pomodoro.py | `id`, `user_id`, `badge_type`, `awarded_at` | +| `WsMessage` | pomodoro.py | `type`, `data` | + +--- + +## 📡 Complete Endpoint Reference + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| `GET` | `/health` | ❌ | Health check | +| `POST` | `/auth/google` | ❌ | Google login → JWT | +| `GET` | `/auth/me` | ✅ | Current user profile | +| `PATCH` | `/auth/me/focus-mode` | ✅ | Switch Acads/Clubs mode | +| `GET` | `/user/{id}/goals` | ✅ | Get career goals | +| `PUT` | `/user/{id}/goals` | ✅ | Set/replace career goals | +| `GET` | `/suggestions/{id}?mode=` | ✅ | LLM suggestions + quote | +| `GET` | `/suggestions/{id}/history` | ✅ | Past suggestions | +| `PATCH` | `/suggestions/history/{id}` | ✅ | Mark completed/dismissed | +| `GET` | `/suggestions/{id}/heatmap` | ✅ | 90-day focus heatmap | +| `GET` | `/timetable/{id}` | ✅ | Get weekly timetable | +| `POST` | `/timetable` | ✅ | Add timetable slot | +| `DELETE` | `/timetable/{slot_id}` | ✅ | Remove slot | +| `GET` | `/calendar/{id}` | ✅ | Unified calendar (merged) | +| `POST` | `/calendar/events` | ✅ | Create manual event | +| `POST` | `/calendar/sync/classroom` | ✅ | Sync Google Classroom | +| `POST` | `/calendar/sync/gmail` | ✅ | Parse Gmail club events | +| `POST` | `/admin/institute-calendar` | 🔐 Admin | Upload CSV | +| `POST` | `/admin/events` | 🔐 Admin | Post club event (pending) | +| `GET` | `/admin/events` | 🔐 Admin | List all admin events | +| `PATCH` | `/admin/events/{id}/approve` | 🔐 Admin | Approve event | +| `DELETE` | `/admin/events/{id}` | 🔐 Admin | Delete event | +| `POST` | `/usage` | ✅ | Batch upload usage data | +| `GET` | `/usage/{id}/rolling-average` | ✅ | 7-day avg + percentile | +| `GET` | `/usage/{id}/should-nudge` | ✅ | Check if nudge needed | +| `POST` | `/pomodoro/sessions` | ✅ | Create session | +| `POST` | `/pomodoro/sessions/join` | ✅ | Join via invite code | +| `GET` | `/pomodoro/sessions/{id}` | ✅ | Get session details | +| `GET` | `/pomodoro/sessions/{id}/leaderboard` | ✅ | Weekly XP leaderboard | +| `GET` | `/pomodoro/badges/{user_id}` | ✅ | User's earned badges | +| `WS` | `/ws/pomodoro/{session_id}?token=` | ✅ | Real-time timer sync | + +--- + +## ⚠️ Key Things to Note for Flutter Integration + +1. **JWT goes in every request header:** `Authorization: Bearer ` +2. **WebSocket auth** is via query param: `?token=` (headers not supported in Flutter's WebSocket) +3. **WorkManager** should call `POST /usage` + `GET /usage/{id}/should-nudge` every 30 min, fire local notification if `should_nudge == true` +4. **Classroom & Gmail sync** — store the Google `access_token` + `refresh_token` at login; backend uses them server-side to call Google APIs +5. **Offline cache** — Flutter should cache `GET /calendar` and `GET /timetable` results in `sqflite` for offline access (FR-17) +6. **Admin role** — set `user.role = "admin"` in DB for club coordinators; the `require_admin` dependency enforces it on admin endpoints