From ff4dd670d419c18a3979c82e5bf91afb9ee329a4 Mon Sep 17 00:00:00 2001 From: Mustafa Cetin Date: Wed, 29 Jul 2026 10:23:50 +0000 Subject: [PATCH] Round 13: PWA Offline Mode, Rate Limiting, Behavior Analytics, Interventions, Audit Logging --- prisma/schema.prisma | 91 + public/icon-192.png | Bin 0 -> 3846 bytes public/icon-512.png | Bin 0 -> 14644 bytes public/manifest.json | 27 + public/sw.js | 300 ++++ src/app/api/audit-log/route.ts | 61 +- src/app/api/auth/route.ts | 27 +- src/app/api/backup/route.ts | 5 +- .../api/behavior-interventions/[id]/route.ts | 173 ++ src/app/api/behavior-interventions/route.ts | 135 ++ src/app/api/data-export/csv/route.ts | 5 +- src/app/api/data-import/route.ts | 5 +- src/app/api/districts/[id]/route.ts | 97 + src/app/api/districts/[id]/schools/route.ts | 60 + src/app/api/districts/route.ts | 60 + src/app/api/rate-limit-stats/route.ts | 16 + src/app/api/report-schedules/[id]/route.ts | 158 ++ .../api/report-schedules/[id]/run/route.ts | 120 ++ src/app/api/report-schedules/route.ts | 137 ++ src/app/api/reports/pdf/route.ts | 5 +- src/app/globals.css | 255 +++ src/app/layout.tsx | 21 +- src/components/app-layout.tsx | 10 + src/components/behavior-tracking-view.tsx | 916 +++++++++- src/components/offline-indicator.tsx | 507 ++++++ src/components/reports-view.tsx | 1553 ++++++++++++----- src/components/settings-view.tsx | 715 +++++++- src/lib/api.ts | 281 ++- src/lib/audit.ts | 140 ++ src/lib/i18n.ts | 396 +++++ src/lib/offline-cache.ts | 257 +++ src/lib/rate-limit.ts | 218 +++ worklog.md | 85 + 33 files changed, 6327 insertions(+), 509 deletions(-) create mode 100644 public/icon-192.png create mode 100644 public/icon-512.png create mode 100644 public/manifest.json create mode 100644 public/sw.js create mode 100644 src/app/api/behavior-interventions/[id]/route.ts create mode 100644 src/app/api/behavior-interventions/route.ts create mode 100644 src/app/api/districts/[id]/route.ts create mode 100644 src/app/api/districts/[id]/schools/route.ts create mode 100644 src/app/api/districts/route.ts create mode 100644 src/app/api/rate-limit-stats/route.ts create mode 100644 src/app/api/report-schedules/[id]/route.ts create mode 100644 src/app/api/report-schedules/[id]/run/route.ts create mode 100644 src/app/api/report-schedules/route.ts create mode 100644 src/components/offline-indicator.tsx create mode 100644 src/lib/audit.ts create mode 100644 src/lib/offline-cache.ts create mode 100644 src/lib/rate-limit.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5823fdd..425e453 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,6 +42,10 @@ model School { parentStudentLinks ParentStudentLink[] curriculumStandards CurriculumStandard[] backups Backup[] + behaviorInterventions BehaviorIntervention[] + reportSchedules ReportSchedule[] + district SchoolDistrict? @relation(fields: [districtId], references: [id]) + districtId String? } model User { @@ -79,6 +83,7 @@ model User { notifications Notification[] calendarEvents CalendarEvent[] parentLinks ParentStudentLink[] + assignedInterventions BehaviorIntervention[] @relation("InterventionAssignee") } model SchoolYear { @@ -124,6 +129,7 @@ model ClassGroup { notebooks Notebook[] drawings Drawing[] calendarEvents CalendarEvent[] + reportSchedules ReportSchedule[] } model ClassGroupTeacher { @@ -160,6 +166,7 @@ model Student { parentContacts ParentContact[] parentMessages ParentMessage[] behaviorIncidents BehaviorIncident[] + behaviorInterventions BehaviorIntervention[] parentStudentLinks ParentStudentLink[] } @@ -427,6 +434,9 @@ model AuditLog { action String entityType String entityId String? + changes String? // JSON: { before: {...}, after: {...} } + ipAddress String? + userAgent String? timestamp DateTime @default(now()) metadata String? // JSON @@ -630,6 +640,8 @@ model BehaviorIncident { @@index([classGroupId, date]) @@index([schoolId, date]) @@index([teacherId]) + + interventions BehaviorIntervention[] } // ─── Rubric Library ──────────────────────────────────────────────── @@ -964,3 +976,82 @@ model Backup { @@map("backups") } + +// ─── Behavior Interventions ───────────────────────────────────────── + +model BehaviorIntervention { + id String @id @default(cuid()) + schoolId String + studentId String + incidentId String? + type String // "warning", "meeting", "action_plan", "support", "parent_contact" + description String + status String @default("planned") // "planned", "in_progress", "completed", "cancelled" + assignedTo String? + startDate DateTime? + endDate DateTime? + outcome String? + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + student Student @relation(fields: [studentId], references: [id]) + incident BehaviorIncident? @relation(fields: [incidentId], references: [id]) + assignedUser User? @relation("InterventionAssignee", fields: [assignedTo], references: [id]) + + @@index([schoolId, studentId]) + @@map("behavior_interventions") +} + +// ─── Report Scheduling ────────────────────────────────────────────── + +model ReportSchedule { + id String @id @default(cuid()) + schoolId String + classGroupId String? + template String // "short", "full", "custom" + frequency String // "weekly", "monthly", "quarterly", "end_of_term" + dayOfWeek Int? // 0-6 for weekly + dayOfMonth Int? // 1-31 for monthly + monthOfYear Int? // 1-12 for quarterly + recipients String? // JSON array of userIds + includeStudents Boolean @default(true) + includeGrades Boolean @default(true) + includeAttendance Boolean @default(true) + includeBehavior Boolean @default(false) + includeCompetencies Boolean @default(true) + isActive Boolean @default(true) + lastRunAt DateTime? + nextRunAt DateTime? + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + classGroup ClassGroup? @relation(fields: [classGroupId], references: [id]) + + @@index([schoolId, isActive]) + @@map("report_schedules") +} + +// ─── School District ──────────────────────────────────────────────── + +model SchoolDistrict { + id String @id @default(cuid()) + name String + code String? // district code + region String? + country String @default("DE") + adminEmail String? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + schools School[] + + @@map("school_districts") +} diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..f7ac6933d22d0fcb51b41c04d84a783913adacd3 GIT binary patch literal 3846 zcmZ{ndpy(a|HnUD8)LaQgh(4XM&j<|&S{hGEOLnC5XM~+6;W6Y+nmx#3sH9p9Va5) zj>XI&F?U5IlEauHn$5A{W4@pDtKXwPzTZDS`+T;~`?_AQ>v_Ge_jT=pv!kt?tePwU z0CIatyIr7ro#aAEL7y&tC#0d9%qf!RX#hYgN-h|1^VV7b_^oR1ZtDY-Tm0Mop=-KT zUtZ7u;&37A+pXmM>kf<3+q79m!-Qj2Udfjpy130%Y-p9+pDyRSXsus@&zXy|so7Ch z7Gz{+5xCDaLFS>I#v{aizb;0!BJ+#TT&Dce)itQuT3x6QYw?_N%E+kYh6xmUJf{$ku&d( z`__$%Vld*Gc$0Hg?5U?q7L!zbj9=5m=h<@=2Gtsmzdm;UV7S*X$}#_|=ACe3nasFp z>%yv?d%~XUM#h=m*|NkdSc=^hFj;i2W&kcG)rWpkd(t|fXb|O_|J7=zCuP%nj+SnR zPgv&J&Ba?*_IarKcs}7SB@P}M*IGzSASc0AF225Q8uZCn*KKm=&f3v<59Yq>c9*KU zScd))ZJQJE>R`U5Jc)XIJ7{QXnq3xd+{{qQ;A@d5Tq)?q}Y{`KVk1myx@WcTjLA!jb*P&M8u{}q#tkBZr? zS4Zx@?t+Ux7=4P)+b}Q-Hu#(dLqS4^LbtPRNBB&Zu#v!GtxSrI&sTI_14vO7Gi+uw4AN zg{H-Hwk$2;bq?!z0>SFpk~E#*#mOC!qD5pJZHpueqa2Rlt`ttTT?Of6YzjMENk3MZ z;2YawD`;kTlf%jY<20xPvDbvX&DeO)3@n=wUq7OCyz{Mt0TIg<&mwc#m<( zy_uOJOL=jkd$&(%6?lGkU#oXrsinn`?Wk!m%DD_lp?&GYsr$f1e_rDnqy73mQpyNQ zhIp@Jrdb+zFo{df;>uKuYc4N=HNhA&_FHwaom}+W+7;T`wwalD{Ov)Z6z!9&iJHAf zb=z=&a-H#Aml*Zz15Ht14?7kQPf0Cu7`=x@32d+U;vRRpYRya1I_f)IBLV!hJ){@@ zinrdk&Uk}MjJJ6pn$9%4pi2m5JjIF1omss)fMaMLT=G3cd8s~#|a7bcJG032@B{|z{D2Wy*yW-rtxElB!tNQrU9h0`-S)B*T&Zm?gpT2 zo7r@nAq{hm>6L?emj45cKoF-ZC6=_-h?2)oqwA!_%)uBRa&C1|=o?z`VAa%}fwNy6 zmRVoA%WjpyUKYLaB7{HCMMqD}`m<&__F9JV{;4e&Cf8a;*@n^t`u31lVS#1$=a31n zt<|TxbzaoAywACYrYsFUeD9rI-C5hY4xTiq3FMBDV@qC7^ohfL*%1>3)P=;2EdIVn z@mP!CwyZ~q(alY!bE0Pa_}^xA-D)8m0hW#jsfc`9vM5_TFiUbn-4rr)XCNDQ9?A#; z{%9a8(_Q&tkxK_1{yfi^ePgdzu>}uWSrF1ur z!}boc61oL}SNyU-D@2|&?JE48%J8*;$fNuI<*bTtUOFaZxQ zqayD0@)V#^V>M2|R)=#0uFw!{=q5;$mIlFoAR2luL70%<%v3%#AYfkmi69>SzYn-(D52N%-uaD*nk{x|A%AQm#hSI8sX zo!`^|aaIB64KLm(LF%X5QBwdAdRc_8$5J5~$X3!uVwyL9Y9nAN(jHv5s&>T6YlLl^vQR+i;Uz~$ju}GZWUhM%mij3O#U+MwmTLq zWr>Q^rR?e(c~BD!4U(!CfMeTqNw7syHx9Ct2mvxr@SHZq(yhQMI6{YrHxxZki$oJK zR8tXFq4jQ5O&McL-_GP zed-Yu`v5xvO(ZOYAyIa8YNW6l!Cgf#a5hJ-)mX*00dVDkXHUa4u>7>uL(r@kE=cCq z9I;kIn(YJ|tAqF}`_Z)}SoZ%=g_6`uqIv_q`s%sCL|~gl)rJn67MjY_eSfG*5<{0z z3|OO0>ZHVG&>F!j>FdL!JnD_R3PWJWx^XCMAL*RLO&SEN%$08KA~Z*Ek?fLbm_N4v z|J*R4%>C}*>URej(3-gcz4@jOr71KXd%AUpFa}FM_)}C7c1$Abk1}b2i>2W0=O9rM zOzJ}&*%o+SE)7SRrfY&O>(Bf@59?ofZ1~9o{a<)&_=U&$|HUKi7ap2Fc-+yb;~T5Y z9;y`Ii&~x#M1P#c&)suPnZ1J6n-k43W?wb<^gohJCJ}hf84Q9~#XEV%U3pS$UO6pu z*GNw#xvFpfE{K%u{zg`kG3QosyY;RULEo_&#O z7(M!+a_)Vn?Km)q6ZKbf)ZQI(Vi)JKw-xDycpI%$Rt9U^hzjAtN0tra8ynd;iJJ$N zIbxPH_mDKLknm@(ohB3!ha&1NEX$U72R)pt2r?f!c{O!B8p&pH1f%T*MtU@6l=mMG znxBy0Zna9{qE=*Kf7FKx%B!O+bZZnTiQB=+{v&c(J!fTp-c`$f@0_2OdRX!O#vy;+iUJUZPyc(mPCZ{e9$ zk8YfVF|=2v^fcxc7y(F83`vis*M(E9A8|6`pFaf1LOUuTPMmP0u7k=a9bX=(FG;{hLYsHrxcX7om{?hQZ(_A&q6WS!avzX{UbpkUmk}hv2)`7k1$n@xFz1@^}l9IZA z&uVe&l^R7wgBTQJ8_!|0Y8tfP^P&;R|`HS3^U}0gp zvf_j0!x*oo*Si7)Lk^p3>NZ1VGHbpt6t~{K>8rd!6fQJ;s1K)_Hu6L)CGB_KI~;+`7wZZYZ}psWy{WSmioGn> ze}ATIK;yvBNp<)0TCF;UB741)71?O)rKab);TUx0%Id&~;aDmM5MSH5_~kS2Fy?Fm z?UAhKGv+C~tyYejK?9@Yrw!_H4Jx3$LOy79tK+h9DXD-%9_YyFxoJBgx7UrLG&;j8 z-JR|?&3x72vFUcLaYgaYk2JRPf~h_&15G|WvU0*Hcn;5^J^hece#G%UJ8K)dk&egT z&p>dU9=&y$;6@GLw#Ur7*imCse^uHPA7<+-OaePE%J*U{aqVpz~Vv>VEoBUz-F(|>`^>SD2Za(dvqdQ9sJ1s&Y_AH0AwZyin|DE=^<pVKI-V^X2E^)v?Hd^mh5ZTael*$1*a;?H0Ix=mb4ffLshxpp_I(>YO-(W8G|LVE1reX5B^aXjn zi>A1s^T{sggYvz7caJJ>a6GqmQFU_H?e+JPoAZaB_Lq2-yE+?b8QvzD7KW}<7v+-S z&!++^)^2n_z8If(zSn%ppoqObnwzg)KRn^uw=-F;BF|@&ZyMbonM$c2vO|ruBHv%M zYJV?-b&6_-Qu=JU%nOm|`3`^i;*d*EKQrX3e0o10Og)xH-z~XEo+Z>QQTH*~S!a-% z!Bj93ICqvMQwyQ`4w|F4F-G^H!~zNZ4(6Rv)oKNQumKQu#hHdhYijr>q( z`7GO;m21_%4cVN^Jbfy|JK84xLvO9RwYJeeB-54@a+e=f3Dp&S;F;tzs?@OeEo|q4 zMgE3-mAu}tQc@DmKlr)zonwc^P14r*zC&$8XTJ57FdfiW;t-k(^izP|?xj_N1?+qI44ql&$3FWf^-96nVU5{d4U!tJ!R$kPs;HBB~4 z&umz_hH;_iqi5ebJA09}50877$`_?`%M8(AaSK@(bSZxF&BCBVL)6szE!?eFZrtSN zC+?q4q8gxXKX-63_GyRoj#ajbZwu;7>$)B)=X>-Xs!knx+L8JupM-Hnewcq?P48(^ zhGKMea$JprNeVqO(s7Zr6}{E(V@grZk9Lj-?&LA~+mO}VCteF9CVt_w2T%Qh zI~|{P{%-!^C*1aju3fEFaqY)kFf(A-$i<>DAe5DoMfD-9TeGF}-Wi&nZ@34REoHo)EqUPj)pqXb#;RjOonGZDZ>{}htghFLJ5!`R zHr@Qb&wEE{EO^uI_ZMS4bEw~k8y~+pf!YXl>Y24*7gJLER8kV6dW}tL`U>^Qi^y)` zJ4|L5wKZj;455}L1i0yK%h&QQ5^9$Ma*u`=K!>1x$wtPY#@v#?w}9 z4RevUx(Hl4-lr(LrqB>gDH~2)k!h}GX|I@+`p?bHCtTGm?Mirj_ZO_qw(3)?vx+}e zvQqlCtFN=ugDckgA_q5DdliShM&?oHwt){(>&so|k9(iaTzogxc%XY%F3Y-pNiwH2TsVi;*vY zk8+4O0G)t!csUkZ#aGT{MJG4+dhJ|O6q@Y?kV>WvpRNpv_puy^uL(v|R5(YBzMW+^ zOBwI2lO5wH5;jfPlVh{ z{%O;jsM8P+&3R3D)uy&Tc(L`=$Ohasfc|gN;0Kyt84YHpZ2WoJYPH>6^n=&QwI7RY0MnyFhCFxBKiZ>Ksw{l#_NwWt$v1s+h$-&^Ak~BSdOw)7CZmtqAYbGDsbAUG1Xy=2RdMJL zw=Cc2Jy<+5u3n%=mCzmbS#=rzXc%7>u8KTgAHJWxGIK_Eu|KsyxS}zvC;JM2d!Nu} zrLrJx2NJv_Vupul;$$B_EeFg1JT`(#ysI7_ClsXJR2-(9oQKS^7rL~il{h8!GE?h7$T?ET%GG3*S)0OV^ve_qb7tgoNqP!nVZieNa`I4d9TWUNbbJ$Mf-{z16CMz{=%O;xg^rntq%U*xhx$$esTOrytY5Zaq zzox$VrNQ5#UB9e$aRK$hFAYwMlz$mvg)e%3ya_gL;?cCmZ`j+(n1VDidYS+MNo8g} z`p+*tWP2EYje9NpNi*gAz?xs01oTw@V%HVH=zF*{lf0*Mfvurxv2#_3pKmTug%kP_pHkFn4Wc{CfevJSGIEKo7Hiy zGah%q$j7CZ?^=JZu#(w5JG`eXX@(_-GAoC=2`-JPhN}e6Hg~Q(r$LEd!Ww!!%p^5D zT>AL{j0O1Fa@H|n)Nu2LbFZ58s1c&B_j@R_3|B0X_GQU?C|h_pa>&c!mXvvSvOf=* zBq(dr;^ZcY&8_g_tad;C=ct=kR=TIdG+qcO(eX)~kxLn=B974*KYO*y!Ct*TQg3TY zaF|m?S&dEPxx`3#3C=G(udSh0 ziB0QED@>*eCTFB|Qbl!FZom4%o2! z4Vhs_z<&d%_b%gCATY@2+YqIJcRkUeDRS9ZUsr<7`tL)|Wz@6%l!oPx5=Cvdzyb;@ z?pd2$S9)Uza&j=qC+OF#Mt`bWhYE!nTZIp6wzKdr(+7eLZkPB&LZo@|!Wuus8V!Y_ z=3-W3tWQsG-@AgtZ%K5q{{k!lPMkz-tR7s`uRf7gdhYH72*dVJe`nhy-5@!3yG$=x zGC4M!cWA&}_~P-z?@vy5Qso4W+q5C?j>X+iL8UFx~Fdq?cS;8rXPr3S>zpa*(cbZ=cAn zF(n#A-aWtxSc=20M3mA*+*TpSCo?ryfjN#wgEZ?JG#f#*G?MX)2nF{?#cn6>D5JOe&*&h}&tq1+9^uDW zh4NIJJoL=VdqzSIRp%@v|4qKp75tOH>Kp`<8%BDQjP| zcY+V-z~^ogq>;dP$Kf#g*g^;)2~-l2XfdL@TG~F)J0521Z4v;vpjHfpPm|-I^-g-FRTr2>5kU^N?*=*P;kjTWAwm{o020WuOyM=igG_59Z zV-9gs)k5GqBAt`6vyzfV*>Ew}6Go@jhwuR) zIqrTA|D5mTMefy5vGgMnvvH_+%^+N?PXQocAw2v5b6XSU_5d(>*c<`s&A{9<2y-tm z4t$nyOwbYmkwawW-08^1+PyBqPdv?EnBitE^0br_$Znve;l_(c6PNcAQCblbMY)XQ zoug08WF->p_-txm@2eV6`U<-(C?n7pO<1Q|3Ms@^L~roXB?!ZXFc94Jwq=+O@#rTk z+WrfRw*SDQdm?sH9?@1x{Dm06H3`}_1q%R1%m^S5qyP|Pn9n{4K2Ra{bRu~Dn2Q26 zkX&~!B51$^c3OzZLYxqWcs2{*#N*0nn1qD2PYDiFNTldS1PB(xjm2}HgHFQpaHeO| zA(zU10#-qpJV-$|;d|i+Etmi&dIU~x5IDJIP6C|>-g-t5u-vEPlF}%HSY8)l@?{D8 z!SZwnV$g@gKzit^G+E?LQ00r!saxR(0HJXWGT0WSzBYG5A7~9Kk^lq5S&cax5XxhaQYJ$T5ZQIC!nOM+v{f-dD`XGgN+3GO7@Mt{ z-hk_9v;P>Z09NRWHrPF{yD7$Ya3 z0XiUIJwz*e_()Tb2KSgX8*bF;g}`O|<z|?lXy40QQ|#CWr-CV7rQBLXm}wqT!reK`f*gIilAa zXi(PN-?>4_E)sO65%ggSI_07pen$c5GBpf%#D*zLNJjK}T1>hw)TApHMdL_xfSae;-Hh!|1_T39f9;@M6DJYosv0yrBv!Tb4Yy&L?^{4Dv5dNDub zekLf>=nq1+07fEzt z3>t*eC6B}_f|Q}dRXCb59Ce3F3+v-UP<9ZMKe8d1e8?FAYDqE<54+D?rwfKZSq-vJNQC4Awyb`b6i zf)9kmgwI<*=@ur!AjB^Lma{7yA2s^b4oJzWkc4jsfs@0$b_5-)j-=tDL6~SygO6%K zAR~+P9>P4umRLBr1_ofbMdT^LaBof!=2j!}6oRnaR0OdN$jjWvj+kxY_Ww_@V|t5s z8#FG3;JI)qKqV?E;`DY9VSez#ZikVmai#`TIRR$Ir<5KDXAd>}!(6H=^dc&eww;EK z=`sVKNjw^HeULn{TxQ{R#3Lhu4PXH8M{skvG1WxYnF#hT45XL=$kRH`*7nB-_{{!F z!rhj_Pi8kct_FSViJS#8^+z3%60m|hVTE+?1`Ll&tb5>tL?WU;0X`abx7iU?tysl` zs~3amV+zM81LmBSa2V&Y^AjMccSNXVUx&*YK}8i2B_95sO|TXw&;XXyhzJGqT^{K4 zTino#sg#z-vI$13hv32=K6j9Std(qL&p?nFbx;ccQ|VUd7GTKV(oM&$_LD$2h?hDm z4zpqzQwgxgzB5W~gs<%YJp&GNp(mqP;Rt1#AY#O<9<=4c@IcfA!3j?*0vyHwkArN3 z=yn@f`0fQbdpSaDUV|r2F+3f&agy=1gy}~BGKz-r02xKYcz}Sz z2diItJV1WJ;0Ylz;3N?=V>vE|nBu1S{9P4vsQ>L4(mw;g4)YPhEuX1E7zLw&V7cBo zkOcM-$FdD}(7C}W8u&YLDk8;zV6fBY`w+__nCl?;(WrA3*H1 zNc)u|$Xh#9!LeiGS^+4A@BJ z$mVNzO~SAKA4QNg3Qs?NXQ5NSx}Jhu4bCDr;qu7pa1YaGAs~fO7q*U8%0^BTFo{zy zL@glxMk0tM*nEm%4hs+|Su{W(YAmssi#)md{IA3RJ#~2ee(Es& yBKc^0l-%lN3 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 literal 0 HcmV?d00001 diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..4f5156f --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,27 @@ +{ + "name": "CompetenceTrack", + "short_name": "CompTrack", + "description": "Kompetenz- und Benotungsplattform für Schulen", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#10b981", + "orientation": "any", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "categories": ["education", "productivity"], + "lang": "de", + "dir": "ltr" +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..fe8a2a6 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,300 @@ +// CompetenceTrack — Service Worker for PWA Offline Support +const CACHE_NAME = 'competencetrack-v1'; +const STATIC_CACHE = 'competencetrack-static-v1'; +const DATA_CACHE = 'competencetrack-data-v1'; +const OFFLINE_CACHE = 'competencetrack-offline-v1'; + +// Static assets to cache on install +const STATIC_ASSETS = [ + '/', + '/manifest.json', + '/logo.svg', + '/robots.txt', +]; + +// Install event — cache static assets and offline fallback +self.addEventListener('install', (event) => { + event.waitUntil( + Promise.all([ + caches.open(STATIC_CACHE).then((cache) => { + return cache.addAll(STATIC_ASSETS).catch((err) => { + console.warn('[SW] Some static assets failed to cache:', err); + }); + }), + caches.open(OFFLINE_CACHE).then((cache) => { + return cache.add('/').catch(() => { + // Fallback: just create the cache + }); + }), + ]).then(() => { + console.log('[SW] Installed and caches populated'); + return self.skipWaiting(); + }) + ); +}); + +// Activate event — clean up old caches +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => { + return Promise.all( + keys + .filter((key) => key !== CACHE_NAME && key !== STATIC_CACHE && key !== DATA_CACHE && key !== OFFLINE_CACHE) + .map((key) => caches.delete(key)) + ); + }).then(() => { + console.log('[SW] Activated and old caches cleaned'); + return self.clients.claim(); + }) + ); +}); + +// Fetch event — routing strategy +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET requests (POST, PUT, DELETE) — handle with background sync + if (request.method !== 'GET') { + // Queue write operations for background sync + if (navigator.onLine === false) { + event.respondWith( + new Response(JSON.stringify({ queued: true, offline: true }), { + status: 202, + headers: { 'Content-Type': 'application/json' }, + }) + ); + // Store the request for later sync + storeRequestForSync(request); + return; + } + return; // Let non-GET requests go through normally when online + } + + // Skip chrome-extension and other non-http requests + if (!url.protocol.startsWith('http')) return; + + // API data requests — NetworkFirst strategy + if (url.pathname.startsWith('/api/')) { + event.respondWith(networkFirstStrategy(request, DATA_CACHE)); + return; + } + + // Static assets (CSS, JS, images) — CacheFirst strategy + if ( + url.pathname.endsWith('.css') || + url.pathname.endsWith('.js') || + url.pathname.endsWith('.png') || + url.pathname.endsWith('.jpg') || + url.pathname.endsWith('.svg') || + url.pathname.endsWith('.ico') || + url.pathname.endsWith('.woff2') || + url.pathname.endsWith('.woff') + ) { + event.respondWith(cacheFirstStrategy(request, STATIC_CACHE)); + return; + } + + // Navigation requests — NetworkFirst with offline fallback + if (request.mode === 'navigate') { + event.respondWith(networkFirstWithOfflineFallback(request)); + return; + } + + // Default: NetworkFirst + event.respondWith(networkFirstStrategy(request, CACHE_NAME)); +}); + +// NetworkFirst strategy — try network, fall back to cache +async function networkFirstStrategy(request, cacheName) { + try { + const networkResponse = await fetch(request); + if (networkResponse.ok) { + const cache = await caches.open(cacheName); + cache.put(request, networkResponse.clone()); + } + return networkResponse; + } catch (error) { + const cachedResponse = await caches.match(request); + if (cachedResponse) { + return cachedResponse; + } + // Return offline response for API calls + if (new URL(request.url).pathname.startsWith('/api/')) { + return new Response(JSON.stringify({ error: 'offline', message: 'No cached data available' }), { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response('Offline', { status: 503 }); + } +} + +// CacheFirst strategy — try cache, fall back to network +async function cacheFirstStrategy(request, cacheName) { + const cachedResponse = await caches.match(request); + if (cachedResponse) { + return cachedResponse; + } + try { + const networkResponse = await fetch(request); + if (networkResponse.ok) { + const cache = await caches.open(cacheName); + cache.put(request, networkResponse.clone()); + } + return networkResponse; + } catch (error) { + // No cache and no network + return new Response('Offline', { status: 503 }); + } +} + +// NetworkFirst with offline fallback page +async function networkFirstWithOfflineFallback(request) { + try { + const networkResponse = await fetch(request); + if (networkResponse.ok) { + const cache = await caches.open(CACHE_NAME); + cache.put(request, networkResponse.clone()); + } + return networkResponse; + } catch (error) { + const cachedResponse = await caches.match(request); + if (cachedResponse) { + return cachedResponse; + } + // Fall back to cached root page + const fallbackResponse = await caches.match('/'); + if (fallbackResponse) { + return fallbackResponse; + } + return new Response('Offline — Please check your connection', { + status: 503, + headers: { 'Content-Type': 'text/html' }, + }); + } +} + +// Store failed requests for background sync +async function storeRequestForSync(request) { + const db = await openIndexedDB(); + const tx = db.transaction('pending-requests', 'readwrite'); + const store = tx.objectStore('pending-requests'); + const requestData = { + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + body: await request.text().catch(() => ''), + timestamp: Date.now(), + }; + store.add(requestData); + await tx.done; + + // Register for background sync + self.registration.sync.register('competencetrack-sync'); +} + +// Open IndexedDB for storing pending requests +function openIndexedDB() { + return new Promise((resolve, reject) => { + const request = indexedDB.open('competencetrack-sync-db', 1); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains('pending-requests')) { + db.createObjectStore('pending-requests', { keyPath: 'timestamp' }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +// Background sync event — replay queued requests when back online +self.addEventListener('sync', (event) => { + if (event.tag === 'competencetrack-sync') { + event.waitUntil(replayPendingRequests()); + } +}); + +async function replayPendingRequests() { + const db = await openIndexedDB(); + const tx = db.transaction('pending-requests', 'readwrite'); + const store = tx.objectStore('pending-requests'); + const allRequests = await store.getAll(); + + for (const reqData of allRequests) { + try { + const headers = new Headers(reqData.headers); + const response = await fetch(reqData.url, { + method: reqData.method, + headers, + body: reqData.method !== 'GET' ? reqData.body : undefined, + }); + if (response.ok) { + store.delete(reqData.timestamp); + } + } catch (error) { + console.warn('[SW] Failed to replay request:', error); + // Keep the request in the store for next sync attempt + } + } + await tx.done; +} + +// Push event — show notification (if push notifications are configured) +self.addEventListener('push', (event) => { + if (!event.data) return; + const data = event.data.json(); + const options = { + body: data.message || '', + icon: '/icon-192.png', + badge: '/icon-192.png', + vibrate: [100, 50, 100], + data: { + url: data.url || '/', + }, + }; + event.waitUntil( + self.registration.showNotification(data.title || 'CompetenceTrack', options) + ); +}); + +// Notification click — open the app +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const url = event.notification.data?.url || '/'; + event.waitUntil( + self.clients.matchAll({ type: 'window' }).then((clients) => { + // Focus existing window if available + for (const client of clients) { + if (client.url.includes(url) && 'focus' in client) { + return client.focus(); + } + } + // Open new window + return self.clients.openWindow(url); + }) + ); +}); + +// Message event — handle messages from the app +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } + if (event.data && event.data.type === 'CLEAR_CACHES') { + caches.keys().then((keys) => { + Promise.all(keys.map((key) => caches.delete(key))).then(() => { + console.log('[SW] All caches cleared'); + }); + }); + } + if (event.data && event.data.type === 'CACHE_URLS') { + const urls = event.data.urls || []; + caches.open(STATIC_CACHE).then((cache) => { + cache.addAll(urls).catch((err) => { + console.warn('[SW] Failed to cache URLs:', err); + }); + }); + } +}); diff --git a/src/app/api/audit-log/route.ts b/src/app/api/audit-log/route.ts index 3975139..5a4dfeb 100644 --- a/src/app/api/audit-log/route.ts +++ b/src/app/api/audit-log/route.ts @@ -12,8 +12,13 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url); const schoolId = searchParams.get('schoolId'); const action = searchParams.get('action'); + const entityType = searchParams.get('entityType'); + const userId = searchParams.get('userId'); const startDate = searchParams.get('startDate'); const endDate = searchParams.get('endDate'); + const page = parseInt(searchParams.get('page') || '1', 10); + const pageSize = parseInt(searchParams.get('pageSize') || '50', 10); + const exportCsv = searchParams.get('export') === 'csv'; const where: Record = {}; @@ -25,6 +30,8 @@ export async function GET(request: Request) { } if (action) where.action = action; + if (entityType) where.entityType = entityType; + if (userId) where.userId = userId; if (startDate || endDate) { where.timestamp = { @@ -33,10 +40,52 @@ export async function GET(request: Request) { }; } + // CSV export + if (exportCsv) { + const entries = await db.auditLog.findMany({ + where, + orderBy: { timestamp: 'desc' }, + take: 10000, + include: { + user: { + select: { id: true, firstName: true, lastName: true, email: true }, + }, + }, + }); + + const header = 'Timestamp,User,Action,Entity Type,Entity ID,Changes,IP Address,User Agent,Metadata'; + const rows = entries.map((e) => { + const userName = e.user ? `${e.user.firstName} ${e.user.lastName}` : 'System'; + const escape = (s: string | null) => (s ? `"${s.replace(/"/g, '""')}"` : '""'); + return [ + new Date(e.timestamp).toISOString(), + escape(userName), + escape(e.action), + escape(e.entityType), + escape(e.entityId), + escape(e.changes), + escape(e.ipAddress), + escape(e.userAgent), + escape(e.metadata), + ].join(','); + }); + + const csv = [header, ...rows].join('\n'); + return new Response(csv, { + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': 'attachment; filename="audit-log.csv"', + }, + }); + } + + // Regular paginated response + const total = await db.auditLog.count({ where }); const entries = await db.auditLog.findMany({ where, orderBy: { timestamp: 'desc' }, - take: 200, + skip: (page - 1) * pageSize, + take: pageSize, include: { user: { select: { id: true, firstName: true, lastName: true }, @@ -44,7 +93,15 @@ export async function GET(request: Request) { }, }); - return NextResponse.json(entries); + return NextResponse.json({ + entries, + pagination: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }, + }); } catch (error) { console.error('AuditLog GET error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts index 4100423..c52938f 100644 --- a/src/app/api/auth/route.ts +++ b/src/app/api/auth/route.ts @@ -8,6 +8,7 @@ import { getSession, clearSession, } from '@/lib/auth'; +import { withRateLimit, checkRateLimit, getRateLimitKey, getRateLimitHeaders, createRateLimitResponse } from '@/lib/rate-limit'; const loginSchema = z.object({ email: z.string().email(), @@ -23,7 +24,7 @@ const registerSchema = z.object({ role: z.enum(['TEACHER', 'SCHOOL_ADMIN', 'SUPER_ADMIN']).default('TEACHER'), }); -export async function POST(request: Request) { +export const POST = withRateLimit(async function POST(request: Request) { try { const body = await request.json(); const { action } = body; @@ -138,15 +139,33 @@ export async function POST(request: Request) { { status: 500 } ); } -} +}, 'auth'); + +export async function GET(request: Request) { + // Apply rate limit for auth check endpoint + const key = getRateLimitKey(request); + const result = checkRateLimit(key, 60, 60 * 1000); // 60 per minute for GET + + if (!result.allowed) { + const rateLimitResponse = createRateLimitResponse(result); + return NextResponse.json(rateLimitResponse.body, { + status: rateLimitResponse.status, + headers: rateLimitResponse.headers, + }); + } -export async function GET() { try { const session = await getSession(); if (!session || !session.user) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } - return NextResponse.json(session.user); + + const response = NextResponse.json(session.user); + const rateLimitHeaders = getRateLimitHeaders(result); + for (const [headerKey, headerValue] of rateLimitHeaders.entries()) { + response.headers.set(headerKey, headerValue); + } + return response; } catch (error) { console.error('Auth me error:', error); return NextResponse.json( diff --git a/src/app/api/backup/route.ts b/src/app/api/backup/route.ts index c413a70..521dee7 100644 --- a/src/app/api/backup/route.ts +++ b/src/app/api/backup/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; +import { withRateLimit } from '@/lib/rate-limit'; // GET /api/backup — List backups for a school export async function GET(req: NextRequest) { @@ -21,7 +22,7 @@ export async function GET(req: NextRequest) { } // POST /api/backup — Create a new backup or restore from backup -export async function POST(req: NextRequest) { +export const POST = withRateLimit(async function POST(req: NextRequest) { try { const body = await req.json(); const { schoolId, action, backupId, notes } = body; @@ -139,7 +140,7 @@ export async function POST(req: NextRequest) { } return NextResponse.json({ error: 'Failed to create backup' }, { status: 500 }); } -} +}, 'heavy'); // DELETE /api/backup — Delete a backup export async function DELETE(req: NextRequest) { diff --git a/src/app/api/behavior-interventions/[id]/route.ts b/src/app/api/behavior-interventions/[id]/route.ts new file mode 100644 index 0000000..ea6b2b6 --- /dev/null +++ b/src/app/api/behavior-interventions/[id]/route.ts @@ -0,0 +1,173 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { logAudit, extractClientInfo } from '@/lib/audit'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const intervention = await db.behaviorIntervention.findFirst({ + where: { id, deletedAt: null }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + incident: { + select: { id: true, description: true, date: true, severity: true }, + }, + assignedUser: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + if (!intervention) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + // If not super admin, restrict to user's school + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== intervention.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + return NextResponse.json(intervention); + } catch (error) { + console.error('BehaviorIntervention GET [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const existing = await db.behaviorIntervention.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { + type, + description, + status: interventionStatus, + assignedTo, + startDate, + endDate, + outcome, + incidentId, + } = body; + + const updated = await db.behaviorIntervention.update({ + where: { id }, + data: { + ...(type !== undefined && { type }), + ...(description !== undefined && { description }), + ...(interventionStatus !== undefined && { status: interventionStatus }), + ...(assignedTo !== undefined && { assignedTo: assignedTo || null }), + ...(startDate !== undefined && { startDate: startDate ? new Date(startDate) : null }), + ...(endDate !== undefined && { endDate: endDate ? new Date(endDate) : null }), + ...(outcome !== undefined && { outcome }), + ...(incidentId !== undefined && { incidentId: incidentId || null }), + }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + incident: { + select: { id: true, description: true, date: true, severity: true }, + }, + assignedUser: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + // Audit log + const clientInfo = extractClientInfo(request); + await logAudit({ + userId: session.user?.id, + schoolId: existing.schoolId, + action: 'UPDATE', + entityType: 'BehaviorIntervention', + entityId: id, + changes: { before: existing, after: updated }, + ...clientInfo, + }); + + return NextResponse.json(updated); + } catch (error) { + console.error('BehaviorIntervention PUT [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const existing = await db.behaviorIntervention.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== existing.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Soft delete + await db.behaviorIntervention.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + // Audit log + const clientInfo = extractClientInfo(request); + await logAudit({ + userId: session.user?.id, + schoolId: existing.schoolId, + action: 'DELETE', + entityType: 'BehaviorIntervention', + entityId: id, + changes: { before: existing }, + ...clientInfo, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('BehaviorIntervention DELETE [id] error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/behavior-interventions/route.ts b/src/app/api/behavior-interventions/route.ts new file mode 100644 index 0000000..85ecd9b --- /dev/null +++ b/src/app/api/behavior-interventions/route.ts @@ -0,0 +1,135 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { logAudit, extractClientInfo } from '@/lib/audit'; + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId'); + const studentId = searchParams.get('studentId'); + const incidentId = searchParams.get('incidentId'); + const type = searchParams.get('type'); + const status = searchParams.get('status'); + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + // If not super admin, restrict to user's school + if (session.user?.role !== 'SUPER_ADMIN' && session.user?.schoolId !== schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const where: Record = { + schoolId, + deletedAt: null, + }; + + if (studentId) where.studentId = studentId; + if (incidentId) where.incidentId = incidentId; + if (type) where.type = type; + if (status) where.status = status; + + const interventions = await db.behaviorIntervention.findMany({ + where, + orderBy: { createdAt: 'desc' }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + incident: { + select: { id: true, description: true, date: true, severity: true }, + }, + assignedUser: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + return NextResponse.json(interventions); + } catch (error) { + console.error('BehaviorIntervention GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const body = await request.json(); + const { + schoolId, + studentId, + incidentId, + type, + description, + status: interventionStatus, + assignedTo, + startDate, + endDate, + outcome, + isDemo, + } = body; + + if (!schoolId || !studentId || !type || !description) { + return NextResponse.json( + { error: 'schoolId, studentId, type, and description are required' }, + { status: 400 } + ); + } + + const intervention = await db.behaviorIntervention.create({ + data: { + schoolId, + studentId, + incidentId: incidentId || null, + type, + description, + status: interventionStatus || 'planned', + assignedTo: assignedTo || null, + startDate: startDate ? new Date(startDate) : null, + endDate: endDate ? new Date(endDate) : null, + outcome: outcome || null, + isDemo: isDemo ?? false, + }, + include: { + student: { + select: { id: true, firstName: true, lastName: true }, + }, + incident: { + select: { id: true, description: true, date: true, severity: true }, + }, + assignedUser: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + // Audit log + const clientInfo = extractClientInfo(request); + await logAudit({ + userId: session.user?.id, + schoolId, + action: 'CREATE', + entityType: 'BehaviorIntervention', + entityId: intervention.id, + changes: { after: intervention }, + ...clientInfo, + }); + + return NextResponse.json(intervention, { status: 201 }); + } catch (error) { + console.error('BehaviorIntervention POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/data-export/csv/route.ts b/src/app/api/data-export/csv/route.ts index d9a57b2..fdb58e2 100644 --- a/src/app/api/data-export/csv/route.ts +++ b/src/app/api/data-export/csv/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { withRateLimit } from '@/lib/rate-limit'; function escapeCsvField(field: string | number | null | undefined): string { if (field === null || field === undefined) return ''; @@ -16,7 +17,7 @@ function toCsvRow(fields: (string | number | null | undefined)[]): string { return fields.map(escapeCsvField).join(','); } -export async function GET(request: Request) { +export const GET = withRateLimit(async function GET(request: Request) { try { const session = await getSession(); if (!session) { @@ -318,4 +319,4 @@ export async function GET(request: Request) { console.error('CSV Export error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } -} +}, 'heavy'); diff --git a/src/app/api/data-import/route.ts b/src/app/api/data-import/route.ts index b1b6380..79c67e9 100644 --- a/src/app/api/data-import/route.ts +++ b/src/app/api/data-import/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { withRateLimit } from '@/lib/rate-limit'; function escapeCsvField(field: string | number | null | undefined): string { if (field === null || field === undefined) return ''; @@ -59,7 +60,7 @@ function parseCsv(text: string): { headers: string[]; rows: ImportRow[] } { return { headers, rows }; } -export async function POST(request: Request) { +export const POST = withRateLimit(async function POST(request: Request) { try { const session = await getSession(); if (!session) { @@ -217,7 +218,7 @@ export async function POST(request: Request) { console.error('Data Import error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } -} +}, 'heavy'); export async function GET(request: Request) { try { diff --git a/src/app/api/districts/[id]/route.ts b/src/app/api/districts/[id]/route.ts new file mode 100644 index 0000000..483b8de --- /dev/null +++ b/src/app/api/districts/[id]/route.ts @@ -0,0 +1,97 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET /api/districts/[id] +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const district = await db.schoolDistrict.findFirst({ + where: { id, deletedAt: null }, + include: { + schools: { + where: { deletedAt: null }, + select: { + id: true, + name: true, + schoolType: true, + country: true, + _count: { + select: { students: true, classGroups: true, users: true }, + }, + }, + }, + }, + }); + + if (!district) { + return NextResponse.json({ error: 'District not found' }, { status: 404 }); + } + + return NextResponse.json(district); + } catch (error) { + console.error('[districts/[id]] GET error:', error); + return NextResponse.json({ error: 'Failed to fetch district' }, { status: 500 }); + } +} + +// PUT /api/districts/[id] +export async function PUT( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await req.json(); + const { name, code, region, country, adminEmail, isActive } = body; + + const data: Record = {}; + if (name !== undefined) data.name = name; + if (code !== undefined) data.code = code || null; + if (region !== undefined) data.region = region || null; + if (country !== undefined) data.country = country; + if (adminEmail !== undefined) data.adminEmail = adminEmail || null; + if (isActive !== undefined) data.isActive = isActive; + + const district = await db.schoolDistrict.update({ + where: { id }, + data, + include: { + schools: { select: { id: true, name: true } }, + }, + }); + + return NextResponse.json(district); + } catch (error) { + console.error('[districts/[id]] PUT error:', error); + return NextResponse.json({ error: 'Failed to update district' }, { status: 500 }); + } +} + +// DELETE /api/districts/[id] +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + + // Remove district from schools first + await db.school.updateMany({ + where: { districtId: id }, + data: { districtId: null }, + }); + + await db.schoolDistrict.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('[districts/[id]] DELETE error:', error); + return NextResponse.json({ error: 'Failed to delete district' }, { status: 500 }); + } +} diff --git a/src/app/api/districts/[id]/schools/route.ts b/src/app/api/districts/[id]/schools/route.ts new file mode 100644 index 0000000..89e3471 --- /dev/null +++ b/src/app/api/districts/[id]/schools/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET /api/districts/[id]/schools — schools in district +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const district = await db.schoolDistrict.findFirst({ + where: { id, deletedAt: null }, + }); + + if (!district) { + return NextResponse.json({ error: 'District not found' }, { status: 404 }); + } + + const schools = await db.school.findMany({ + where: { districtId: id, deletedAt: null }, + include: { + _count: { + select: { students: true, classGroups: true, users: true }, + }, + }, + orderBy: { name: 'asc' }, + }); + + return NextResponse.json(schools); + } catch (error) { + console.error('[districts/[id]/schools] GET error:', error); + return NextResponse.json({ error: 'Failed to fetch district schools' }, { status: 500 }); + } +} + +// POST /api/districts/[id]/schools — assign school to district +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await req.json(); + const { schoolId } = body; + + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + await db.school.update({ + where: { id: schoolId }, + data: { districtId: id }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('[districts/[id]/schools] POST error:', error); + return NextResponse.json({ error: 'Failed to assign school to district' }, { status: 500 }); + } +} diff --git a/src/app/api/districts/route.ts b/src/app/api/districts/route.ts new file mode 100644 index 0000000..befda14 --- /dev/null +++ b/src/app/api/districts/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET /api/districts +export async function GET() { + try { + const districts = await db.schoolDistrict.findMany({ + where: { deletedAt: null }, + include: { + schools: { + where: { deletedAt: null }, + select: { id: true, name: true, schoolType: true, country: true }, + }, + }, + orderBy: { name: 'asc' }, + }); + + // Add computed stats + const result = districts.map((d) => ({ + ...d, + schoolCount: d.schools.length, + totalStudents: 0, // Would need aggregation query in production + })); + + return NextResponse.json(result); + } catch (error) { + console.error('[districts] GET error:', error); + return NextResponse.json({ error: 'Failed to fetch districts' }, { status: 500 }); + } +} + +// POST /api/districts +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { name, code, region, country, adminEmail } = body; + + if (!name) { + return NextResponse.json({ error: 'name is required' }, { status: 400 }); + } + + const district = await db.schoolDistrict.create({ + data: { + name, + code: code || null, + region: region || null, + country: country || 'DE', + adminEmail: adminEmail || null, + }, + include: { + schools: { select: { id: true, name: true } }, + }, + }); + + return NextResponse.json(district, { status: 201 }); + } catch (error) { + console.error('[districts] POST error:', error); + return NextResponse.json({ error: 'Failed to create district' }, { status: 500 }); + } +} diff --git a/src/app/api/rate-limit-stats/route.ts b/src/app/api/rate-limit-stats/route.ts new file mode 100644 index 0000000..e8a959b --- /dev/null +++ b/src/app/api/rate-limit-stats/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server'; +import { getRateLimitStats } from '@/lib/rate-limit'; + +// GET /api/rate-limit-stats — Get rate limit statistics (admin monitoring) +export async function GET() { + try { + const stats = getRateLimitStats(); + return NextResponse.json(stats); + } catch (error) { + console.error('Rate limit stats error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/report-schedules/[id]/route.ts b/src/app/api/report-schedules/[id]/route.ts new file mode 100644 index 0000000..0187051 --- /dev/null +++ b/src/app/api/report-schedules/[id]/route.ts @@ -0,0 +1,158 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET /api/report-schedules/[id] +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const schedule = await db.reportSchedule.findFirst({ + where: { id, deletedAt: null }, + include: { + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + school: { select: { id: true, name: true } }, + }, + }); + if (!schedule) { + return NextResponse.json({ error: 'Schedule not found' }, { status: 404 }); + } + return NextResponse.json(schedule); + } catch (error) { + console.error('[report-schedules/[id]] GET error:', error); + return NextResponse.json({ error: 'Failed to fetch schedule' }, { status: 500 }); + } +} + +// PUT /api/report-schedules/[id] +export async function PUT( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await req.json(); + const { + classGroupId, + template, + frequency, + dayOfWeek, + dayOfMonth, + monthOfYear, + recipients, + includeStudents, + includeGrades, + includeAttendance, + includeBehavior, + includeCompetencies, + isActive, + } = body; + + const data: Record = {}; + if (classGroupId !== undefined) data.classGroupId = classGroupId || null; + if (template !== undefined) data.template = template; + if (frequency !== undefined) data.frequency = frequency; + if (dayOfWeek !== undefined) data.dayOfWeek = dayOfWeek; + if (dayOfMonth !== undefined) data.dayOfMonth = dayOfMonth; + if (monthOfYear !== undefined) data.monthOfYear = monthOfYear; + if (recipients !== undefined) data.recipients = recipients ? JSON.stringify(recipients) : null; + if (includeStudents !== undefined) data.includeStudents = includeStudents; + if (includeGrades !== undefined) data.includeGrades = includeGrades; + if (includeAttendance !== undefined) data.includeAttendance = includeAttendance; + if (includeBehavior !== undefined) data.includeBehavior = includeBehavior; + if (includeCompetencies !== undefined) data.includeCompetencies = includeCompetencies; + if (isActive !== undefined) data.isActive = isActive; + + // Recalculate nextRunAt if frequency or timing changed + if (frequency || dayOfWeek !== undefined || dayOfMonth !== undefined || monthOfYear !== undefined) { + const current = await db.reportSchedule.findUnique({ where: { id } }); + if (current) { + const freq = frequency || current.frequency; + const dow = dayOfWeek !== undefined ? dayOfWeek : current.dayOfWeek; + const dom = dayOfMonth !== undefined ? dayOfMonth : current.dayOfMonth; + const moy = monthOfYear !== undefined ? monthOfYear : current.monthOfYear; + data.nextRunAt = calculateNextRun(freq, dow, dom, moy); + } + } + + const schedule = await db.reportSchedule.update({ + where: { id }, + data, + include: { + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + }, + }); + + return NextResponse.json(schedule); + } catch (error) { + console.error('[report-schedules/[id]] PUT error:', error); + return NextResponse.json({ error: 'Failed to update schedule' }, { status: 500 }); + } +} + +// DELETE /api/report-schedules/[id] +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + await db.reportSchedule.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + return NextResponse.json({ success: true }); + } catch (error) { + console.error('[report-schedules/[id]] DELETE error:', error); + return NextResponse.json({ error: 'Failed to delete schedule' }, { status: 500 }); + } +} + +function calculateNextRun( + frequency: string, + dayOfWeek?: number | null, + dayOfMonth?: number | null, + monthOfYear?: number | null, +): Date { + const now = new Date(); + const next = new Date(now); + + switch (frequency) { + case 'weekly': { + const targetDay = dayOfWeek ?? 1; + const currentDay = next.getDay(); + const daysUntil = ((targetDay - currentDay + 7) % 7) || 7; + next.setDate(next.getDate() + daysUntil); + next.setHours(8, 0, 0, 0); + break; + } + case 'monthly': { + const targetDate = dayOfMonth ?? 1; + next.setDate(targetDate); + next.setHours(8, 0, 0, 0); + if (next <= now) next.setMonth(next.getMonth() + 1); + break; + } + case 'quarterly': { + const targetMonth = monthOfYear ?? 1; + next.setMonth(targetMonth - 1); + next.setDate(1); + next.setHours(8, 0, 0, 0); + if (next <= now) next.setMonth(next.getMonth() + 3); + break; + } + case 'end_of_term': { + next.setMonth(6); + next.setDate(15); + next.setHours(8, 0, 0, 0); + if (next <= now) next.setFullYear(next.getFullYear() + 1); + break; + } + default: { + next.setDate(next.getDate() + 7); + next.setHours(8, 0, 0, 0); + } + } + return next; +} diff --git a/src/app/api/report-schedules/[id]/run/route.ts b/src/app/api/report-schedules/[id]/run/route.ts new file mode 100644 index 0000000..0616b83 --- /dev/null +++ b/src/app/api/report-schedules/[id]/run/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// POST /api/report-schedules/[id]/run — trigger manual run +export async function POST( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const schedule = await db.reportSchedule.findFirst({ + where: { id, deletedAt: null }, + include: { + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + school: { select: { id: true, name: true } }, + }, + }); + + if (!schedule) { + return NextResponse.json({ error: 'Schedule not found' }, { status: 404 }); + } + + // Mark as run and calculate next run + const now = new Date(); + const nextRunAt = calculateNextRun( + schedule.frequency, + schedule.dayOfWeek, + schedule.dayOfMonth, + schedule.monthOfYear, + ); + + await db.reportSchedule.update({ + where: { id }, + data: { lastRunAt: now, nextRunAt }, + }); + + // In a real system, this would trigger the actual report generation pipeline + // and send notifications to recipients. For now, we simulate success. + const recipientIds: string[] = schedule.recipients + ? JSON.parse(schedule.recipients) + : []; + + // Create notifications for recipients + if (recipientIds.length > 0) { + try { + await db.notification.createMany({ + data: recipientIds.map((userId) => ({ + schoolId: schedule.schoolId, + userId, + type: 'report_ready', + title: `Scheduled Report Generated`, + message: `Report schedule "${schedule.template}" for ${schedule.classGroup?.name ?? 'all classes'} has been generated.`, + actionUrl: '/reports', + relatedId: schedule.id, + })), + }); + } catch { + // Notification creation is non-critical + } + } + + return NextResponse.json({ + success: true, + lastRunAt: now, + nextRunAt, + recipientsNotified: recipientIds.length, + }); + } catch (error) { + console.error('[report-schedules/[id]/run] POST error:', error); + return NextResponse.json({ error: 'Failed to run schedule' }, { status: 500 }); + } +} + +function calculateNextRun( + frequency: string, + dayOfWeek?: number | null, + dayOfMonth?: number | null, + monthOfYear?: number | null, +): Date { + const now = new Date(); + const next = new Date(now); + + switch (frequency) { + case 'weekly': { + const targetDay = dayOfWeek ?? 1; + const currentDay = next.getDay(); + const daysUntil = ((targetDay - currentDay + 7) % 7) || 7; + next.setDate(next.getDate() + daysUntil); + next.setHours(8, 0, 0, 0); + break; + } + case 'monthly': { + const targetDate = dayOfMonth ?? 1; + next.setDate(targetDate); + next.setHours(8, 0, 0, 0); + if (next <= now) next.setMonth(next.getMonth() + 1); + break; + } + case 'quarterly': { + const targetMonth = monthOfYear ?? 1; + next.setMonth(targetMonth - 1); + next.setDate(1); + next.setHours(8, 0, 0, 0); + if (next <= now) next.setMonth(next.getMonth() + 3); + break; + } + case 'end_of_term': { + next.setMonth(6); + next.setDate(15); + next.setHours(8, 0, 0, 0); + if (next <= now) next.setFullYear(next.getFullYear() + 1); + break; + } + default: { + next.setDate(next.getDate() + 7); + next.setHours(8, 0, 0, 0); + } + } + return next; +} diff --git a/src/app/api/report-schedules/route.ts b/src/app/api/report-schedules/route.ts new file mode 100644 index 0000000..dd1d313 --- /dev/null +++ b/src/app/api/report-schedules/route.ts @@ -0,0 +1,137 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +// GET /api/report-schedules?schoolId=xxx +export async function GET(req: NextRequest) { + try { + const schoolId = req.nextUrl.searchParams.get('schoolId'); + if (!schoolId) { + return NextResponse.json({ error: 'schoolId is required' }, { status: 400 }); + } + + const schedules = await db.reportSchedule.findMany({ + where: { schoolId, deletedAt: null }, + include: { + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return NextResponse.json(schedules); + } catch (error) { + console.error('[report-schedules] GET error:', error); + return NextResponse.json({ error: 'Failed to fetch report schedules' }, { status: 500 }); + } +} + +// POST /api/report-schedules +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { + schoolId, + classGroupId, + template, + frequency, + dayOfWeek, + dayOfMonth, + monthOfYear, + recipients, + includeStudents, + includeGrades, + includeAttendance, + includeBehavior, + includeCompetencies, + } = body; + + if (!schoolId || !template || !frequency) { + return NextResponse.json({ error: 'schoolId, template, and frequency are required' }, { status: 400 }); + } + + // Calculate nextRunAt + const nextRunAt = calculateNextRun(frequency, dayOfWeek, dayOfMonth, monthOfYear); + + const schedule = await db.reportSchedule.create({ + data: { + schoolId, + classGroupId: classGroupId || null, + template, + frequency, + dayOfWeek: dayOfWeek ?? null, + dayOfMonth: dayOfMonth ?? null, + monthOfYear: monthOfYear ?? null, + recipients: recipients ? JSON.stringify(recipients) : null, + includeStudents: includeStudents ?? true, + includeGrades: includeGrades ?? true, + includeAttendance: includeAttendance ?? true, + includeBehavior: includeBehavior ?? false, + includeCompetencies: includeCompetencies ?? true, + nextRunAt, + }, + include: { + classGroup: { select: { id: true, name: true, gradeLevel: true } }, + }, + }); + + return NextResponse.json(schedule, { status: 201 }); + } catch (error) { + console.error('[report-schedules] POST error:', error); + return NextResponse.json({ error: 'Failed to create report schedule' }, { status: 500 }); + } +} + +function calculateNextRun( + frequency: string, + dayOfWeek?: number | null, + dayOfMonth?: number | null, + monthOfYear?: number | null, +): Date { + const now = new Date(); + const next = new Date(now); + + switch (frequency) { + case 'weekly': { + const targetDay = dayOfWeek ?? 1; // Monday default + const currentDay = next.getDay(); + const daysUntil = ((targetDay - currentDay + 7) % 7) || 7; + next.setDate(next.getDate() + daysUntil); + next.setHours(8, 0, 0, 0); + break; + } + case 'monthly': { + const targetDate = dayOfMonth ?? 1; + next.setDate(targetDate); + next.setHours(8, 0, 0, 0); + if (next <= now) { + next.setMonth(next.getMonth() + 1); + } + break; + } + case 'quarterly': { + const targetMonth = monthOfYear ?? 1; + next.setMonth(targetMonth - 1); + next.setDate(1); + next.setHours(8, 0, 0, 0); + if (next <= now) { + next.setMonth(next.getMonth() + 3); + } + break; + } + case 'end_of_term': { + // Default: July 15 (end of German school year) + next.setMonth(6); // July + next.setDate(15); + next.setHours(8, 0, 0, 0); + if (next <= now) { + next.setFullYear(next.getFullYear() + 1); + } + break; + } + default: { + next.setDate(next.getDate() + 7); + next.setHours(8, 0, 0, 0); + } + } + + return next; +} diff --git a/src/app/api/reports/pdf/route.ts b/src/app/api/reports/pdf/route.ts index 11cbcda..28acf2e 100644 --- a/src/app/api/reports/pdf/route.ts +++ b/src/app/api/reports/pdf/route.ts @@ -1,8 +1,9 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { withRateLimit } from '@/lib/rate-limit'; -export async function GET(request: Request) { +export const GET = withRateLimit(async function GET(request: Request) { try { const session = await getSession(); if (!session) { @@ -105,7 +106,7 @@ export async function GET(request: Request) { console.error('Report PDF error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } -} +}, 'heavy'); function generatePrintHtml(report: Record, template: string): string { const student = report.student as { firstName: string; lastName: string }; diff --git a/src/app/globals.css b/src/app/globals.css index e90f8dd..be920db 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1548,3 +1548,258 @@ pointer-events: none; } } + +/* ─── Report Scheduling Styles (Task 13) ────────────────────────────────── */ +@layer utilities { + /* Schedule timeline visualization */ + .schedule-timeline { + position: relative; + padding-left: 1.5rem; + } + .schedule-timeline::before { + content: ''; + position: absolute; + left: 0.5rem; + top: 0; + bottom: 0; + width: 2px; + background: linear-gradient(to bottom, oklch(0.55 0.17 155), oklch(0.65 0.12 175)); + border-radius: 1px; + } + .schedule-timeline-item { + position: relative; + padding-bottom: 1.5rem; + } + .schedule-timeline-item::before { + content: ''; + position: absolute; + left: -1.1rem; + top: 0.35rem; + width: 0.75rem; + height: 0.75rem; + border-radius: 50%; + background: oklch(0.55 0.17 155); + border: 2px solid white; + box-shadow: 0 0 0 2px oklch(0.55 0.17 155 / 0.3); + } + .schedule-timeline-item.inactive::before { + background: oklch(0.7 0 0); + box-shadow: 0 0 0 2px oklch(0.7 0 0 / 0.3); + } + .schedule-timeline-item:last-child { + padding-bottom: 0; + } + + /* District card styling */ + .district-card { + position: relative; + overflow: hidden; + border-radius: 1rem; + transition: transform 0.2s ease, box-shadow 0.2s ease; + } + .district-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px -5px oklch(0 0 0 / 0.1), 0 0 0 1px oklch(0.55 0.17 155 / 0.1); + } + .district-card::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(to right, oklch(0.55 0.17 155), oklch(0.65 0.12 175), oklch(0.55 0.17 155)); + } + .dark .district-card::after { + background: linear-gradient(to right, oklch(0.65 0.17 155), oklch(0.75 0.12 175), oklch(0.65 0.17 155)); + } + + /* Cross-school comparison styling */ + .cross-school-comparison { + display: grid; + gap: 1rem; + } + .cross-school-comparison .comparison-bar { + height: 0.5rem; + border-radius: 9999px; + overflow: hidden; + background: oklch(0.95 0.02 155); + } + .dark .cross-school-comparison .comparison-bar { + background: oklch(0.25 0.02 155); + } + .cross-school-comparison .comparison-bar-fill { + height: 100%; + border-radius: 9999px; + transition: width 0.5s ease; + } + + /* Report generation progress bar */ + .report-progress { + position: relative; + height: 0.5rem; + border-radius: 9999px; + overflow: hidden; + background: oklch(0.93 0.02 155); + } + .dark .report-progress { + background: oklch(0.25 0.02 155); + } + .report-progress-fill { + height: 100%; + border-radius: 9999px; + background: linear-gradient(to right, oklch(0.55 0.17 155), oklch(0.65 0.12 175)); + transition: width 0.5s ease; + position: relative; + } + .report-progress-fill::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(90deg, transparent, oklch(1 0 0 / 0.3), transparent); + animation: shimmer-progress 2s ease-in-out infinite; + } + @keyframes shimmer-progress { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } + } + + /* Section divider with gradient line */ + .section-divider { + position: relative; + height: 1px; + background: linear-gradient(to right, transparent, oklch(0.55 0.17 155 / 0.3), transparent); + margin: 1.5rem 0; + } + .dark .section-divider { + background: linear-gradient(to right, transparent, oklch(0.65 0.17 155 / 0.3), transparent); + } + + /* ─── Offline / PWA Styles ──────────────────────────────────────── */ + + /* Offline indicator bar */ + .offline-indicator-bar { + z-index: 100; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 500; + } + + /* PWA install prompt */ + .pwa-install-prompt { + animation: slide-up-fade 0.3s ease-out; + } + + @keyframes slide-up-fade { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + /* Offline badge pulse */ + .offline-badge-pulse { + animation: offline-pulse 2s ease-in-out infinite; + } + + @keyframes offline-pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.6; + } + } + + /* Offline sync spinner */ + .offline-sync-spinner { + animation: offline-sync-spin 1s linear infinite; + } + + @keyframes offline-sync-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + + /* Cached notebook indicator */ + .cached-notebook-indicator { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + /* Rate limit status card */ + .rate-limit-card { + border: 1px solid oklch(0.55 0.17 155 / 0.2); + border-radius: 0.75rem; + padding: 1rem; + background: oklch(0.98 0.01 155 / 0.5); + } + .dark .rate-limit-card { + border-color: oklch(0.55 0.17 155 / 0.15); + background: oklch(0.2 0.02 155 / 0.3); + } + + /* Rate limit bar */ + .rate-limit-bar { + height: 4px; + border-radius: 2px; + background: oklch(0.92 0.01 155); + overflow: hidden; + } + .dark .rate-limit-bar { + background: oklch(0.25 0.01 155); + } + .rate-limit-bar-fill { + height: 100%; + border-radius: 2px; + transition: width 0.3s ease; + } + + /* PWA standalone mode adjustments */ + @media (display-mode: standalone) { + body { + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + } + } + + /* Offline mode visual feedback */ + .offline-mode-active { + filter: none; + } + + .offline-mode-active .offline-dimmed { + opacity: 0.7; + pointer-events: none; + } + + /* Toast rate limit countdown */ + .rate-limit-countdown { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-variant-numeric: tabular-nums; + font-weight: 600; + color: oklch(0.55 0.17 25); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 395a373..2bcd198 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { ThemeProvider } from "next-themes"; @@ -19,9 +19,23 @@ export const metadata: Metadata = { description: "Open-Source School Software for Competency-Based Assessment and Grading. Built with Next.js, TypeScript, and shadcn/ui.", keywords: ["CompetenceTrack", "competency", "grading", "school", "education", "Next.js", "TypeScript", "shadcn/ui"], authors: [{ name: "CompetenceTrack Team" }], + manifest: "/manifest.json", icons: { icon: "https://z-cdn.chatglm.cn/z-ai/static/logo.svg", + apple: "/icon-192.png", }, + appleWebApp: { + capable: true, + statusBarStyle: "default", + title: "CompetenceTrack", + }, +}; + +export const viewport: Viewport = { + themeColor: "#10b981", + width: "device-width", + initialScale: 1, + maximumScale: 1, }; export default function RootLayout({ @@ -30,7 +44,10 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + + + + diff --git a/src/components/app-layout.tsx b/src/components/app-layout.tsx index 0e33a6b..80ad546 100644 --- a/src/components/app-layout.tsx +++ b/src/components/app-layout.tsx @@ -115,6 +115,7 @@ import { } from '@/components/ui/command'; import { toast } from 'sonner'; import { useWebSocket, usePushNotifications, playNotificationSound, getNotificationSoundPref } from '@/lib/websocket'; +import { OfflineIndicator, PWAInstallPrompt, useServiceWorker, OfflineSyncManager } from '@/components/offline-indicator'; import DashboardView from './dashboard-view'; import ClassesView from './classes-view'; @@ -257,6 +258,9 @@ export default function AppLayout() { const [schoolYears, setSchoolYears] = useState([]); const [selectedYearId, setSelectedYearId] = useState(storeSchoolYearId ?? ''); + // Service Worker registration for PWA + useServiceWorker(); + // WebSocket connection for real-time push notifications const { connected: wsConnected } = useWebSocket(); const { notification: pushNotification } = usePushNotifications(); @@ -636,6 +640,12 @@ export default function AppLayout() { return ( + {/* Offline indicator bar */} + + {/* PWA install prompt */} + + {/* Offline sync manager */} + = { Shield, ShieldAlert, Smile, Frown, Meh, Sparkles, Award, CheckCircle2, Circle, Plus, Pencil, Trash2, Filter, X, CalendarIcon, MapPin, UserCheck, PieChartIcon, BarChart3, ListChecks, Loader2, + TrendingUp, Target, Heart, Users, }; function renderLucideIcon(name: string, className?: string): React.ReactNode { @@ -93,6 +100,7 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { ScrollArea } from '@/components/ui/scroll-area'; +import { Progress } from '@/components/ui/progress'; import { toast } from 'sonner'; import { useAppStore } from '@/lib/store'; import { t } from '@/lib/i18n'; @@ -107,6 +115,11 @@ import { deleteBehaviorIncident, fetchClasses, fetchStudents, + fetchBehaviorInterventions, + createBehaviorIntervention, + updateBehaviorIntervention, + deleteBehaviorIntervention, + fetchUsers, type BehaviorCategory, type BehaviorIncident, type BehaviorValence, @@ -116,6 +129,8 @@ import { type BehaviorCategoryInput, type ClassGroup, type Student, + type BehaviorInterventionType, + type UserAccount, } from '@/lib/api'; /* ── Constants ─────────────────────────────────────────────────────── */ @@ -207,6 +222,24 @@ const FOLLOWUP_OPTIONS = [ 'prize', ]; +const INTERVENTION_TYPES = ['warning', 'meeting', 'action_plan', 'support', 'parent_contact'] as const; +const INTERVENTION_STATUSES = ['planned', 'in_progress', 'completed', 'cancelled'] as const; + +const INTERVENTION_STATUS_CONFIG: Record = { + planned: { dot: 'bg-slate-500', badge: 'bg-slate-100 text-slate-700 dark:bg-slate-950/60 dark:text-slate-300', progress: 0, icon: }, + in_progress: { dot: 'bg-amber-500', badge: 'bg-amber-100 text-amber-700 dark:bg-amber-950/60 dark:text-amber-300', progress: 50, icon: }, + completed: { dot: 'bg-emerald-500', badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-300', progress: 100, icon: }, + cancelled: { dot: 'bg-rose-500', badge: 'bg-rose-100 text-rose-700 dark:bg-rose-950/60 dark:text-rose-300', progress: 0, icon: }, +}; + +const INTERVENTION_TYPE_CONFIG: Record = { + warning: { color: 'text-amber-600 dark:text-amber-400', icon: }, + meeting: { color: 'text-violet-600 dark:text-violet-400', icon: }, + action_plan: { color: 'text-teal-600 dark:text-teal-400', icon: }, + support: { color: 'text-emerald-600 dark:text-emerald-400', icon: }, + parent_contact: { color: 'text-rose-600 dark:text-rose-400', icon: }, +}; + /* ── Helpers ───────────────────────────────────────────────────────── */ function pad(n: number): string { @@ -289,6 +322,33 @@ interface CategoryFormState { icon: string; } +interface InterventionFormState { + id?: string; + studentId: string; + incidentId: string; + type: string; + description: string; + status: string; + assignedTo: string; + startDate: string; + endDate: string; + outcome: string; +} + +function emptyInterventionForm(studentId = '', incidentId = ''): InterventionFormState { + return { + studentId, + incidentId, + type: 'meeting', + description: '', + status: 'planned', + assignedTo: '', + startDate: toDateInputValue(new Date()), + endDate: '', + outcome: '', + }; +} + function emptyIncidentForm(studentId = '', classGroupId = '', categoryId = ''): IncidentFormState { const today = new Date(); return { @@ -402,7 +462,7 @@ export default function BehaviorTrackingView() { const [classesLoading, setClassesLoading] = useState(true); const [categoriesLoading, setCategoriesLoading] = useState(true); const [incidentsLoading, setIncidentsLoading] = useState(true); - const [tab, setTab] = useState<'incidents' | 'categories' | 'statistics'>('incidents'); + const [tab, setTab] = useState<'incidents' | 'categories' | 'statistics' | 'analytics' | 'interventions'>('incidents'); // Filters const [filterClassId, setFilterClassId] = useState('all'); @@ -426,6 +486,20 @@ export default function BehaviorTrackingView() { const [savingCategory, setSavingCategory] = useState(false); const [deleteCategoryId, setDeleteCategoryId] = useState(null); + // Interventions state + const [interventions, setInterventions] = useState([]); + const [interventionsLoading, setInterventionsLoading] = useState(false); + const [interventionDialogOpen, setInterventionDialogOpen] = useState(false); + const [interventionForm, setInterventionForm] = useState(null); + const [savingIntervention, setSavingIntervention] = useState(false); + const [deleteInterventionId, setDeleteInterventionId] = useState(null); + const [interventionFilterStatus, setInterventionFilterStatus] = useState('all'); + const [interventionFilterType, setInterventionFilterType] = useState('all'); + const [teachers, setTeachers] = useState([]); + + // Analytics period + const [analyticsPeriod, setAnalyticsPeriod] = useState<'30' | '90' | 'year'>('90'); + const schoolId = currentUser?.schoolId ?? ''; /* ── Load classes & students ─────────────────────────────────────── */ @@ -496,6 +570,34 @@ export default function BehaviorTrackingView() { loadIncidents(); }, [loadIncidents]); + /* ── Load interventions ──────────────────────────────────────────── */ + const loadInterventions = useCallback(async () => { + if (!schoolId) { + setInterventions([]); + return; + } + setInterventionsLoading(true); + try { + const data = await fetchBehaviorInterventions({ schoolId }); + setInterventions(data); + } catch { + setInterventions([]); + } finally { + setInterventionsLoading(false); + } + }, [schoolId]); + + useEffect(() => { + loadInterventions(); + }, [loadInterventions]); + + /* ── Load teachers ───────────────────────────────────────────────── */ + useEffect(() => { + if (schoolId) { + fetchUsers(schoolId).then(setTeachers).catch(() => setTeachers([])); + } + }, [schoolId]); + /* ── Incident actions ────────────────────────────────────────────── */ const openCreateIncident = () => { if (categories.length === 0) { @@ -669,6 +771,199 @@ export default function BehaviorTrackingView() { } }; + /* ── Intervention actions ──────────────────────────────────────── */ + const openCreateIntervention = (studentId = '', incidentId = '') => { + setInterventionForm(emptyInterventionForm(studentId, incidentId)); + setInterventionDialogOpen(true); + }; + + const openEditIntervention = (intv: BehaviorInterventionType) => { + setInterventionForm({ + id: intv.id, + studentId: intv.studentId, + incidentId: intv.incidentId ?? '', + type: intv.type, + description: intv.description, + status: intv.status, + assignedTo: intv.assignedTo ?? '', + startDate: intv.startDate ? toDateInputValue(new Date(intv.startDate)) : '', + endDate: intv.endDate ? toDateInputValue(new Date(intv.endDate)) : '', + outcome: intv.outcome ?? '', + }); + setInterventionDialogOpen(true); + }; + + const closeInterventionDialog = () => { + if (savingIntervention) return; + setInterventionDialogOpen(false); + setInterventionForm(null); + }; + + const handleSaveIntervention = async () => { + if (!interventionForm) return; + if (!interventionForm.type) { + toast.error(t('behavior.intervention.required_type')); + return; + } + if (!interventionForm.description.trim()) { + toast.error(t('behavior.intervention.required_description')); + return; + } + setSavingIntervention(true); + try { + const payload = { + schoolId, + studentId: interventionForm.studentId || students[0]?.id || '', + incidentId: interventionForm.incidentId || undefined, + type: interventionForm.type, + description: interventionForm.description.trim(), + status: interventionForm.status, + assignedTo: interventionForm.assignedTo || undefined, + startDate: interventionForm.startDate || undefined, + endDate: interventionForm.endDate || undefined, + outcome: interventionForm.outcome || undefined, + }; + if (interventionForm.id) { + await updateBehaviorIntervention(interventionForm.id, payload); + } else { + await createBehaviorIntervention(payload); + } + toast.success(t('behavior.intervention.saved')); + setInterventionDialogOpen(false); + setInterventionForm(null); + await loadInterventions(); + } catch (err) { + toast.error(err instanceof Error ? err.message : t('behavior.intervention.error_save')); + } finally { + setSavingIntervention(false); + } + }; + + const handleDeleteIntervention = async () => { + if (!deleteInterventionId) return; + try { + await deleteBehaviorIntervention(deleteInterventionId); + toast.success(t('behavior.intervention.deleted')); + setDeleteInterventionId(null); + await loadInterventions(); + } catch (err) { + toast.error(err instanceof Error ? err.message : t('behavior.intervention.error_delete')); + } + }; + + /* ── Analytics computations ─────────────────────────────────────── */ + const analytics = useMemo(() => { + const now = new Date(); + const periodStart = analyticsPeriod === '30' + ? new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) + : analyticsPeriod === '90' + ? new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000) + : new Date(now.getFullYear(), 0, 1); // this year + + const periodIncidents = incidents.filter((i) => new Date(i.date) >= periodStart); + + // Trend data: group by week + const trendMap = new Map(); + for (const inc of periodIncidents) { + const d = new Date(inc.date); + const weekStart = new Date(d); + weekStart.setDate(d.getDate() - d.getDay()); + const key = toDateInputValue(weekStart); + const existing = trendMap.get(key); + if (existing) { + existing[inc.category.valence] += 1; + } else { + trendMap.set(key, { + week: formatDateShort(key, locale), + positive: inc.category.valence === 'positive' ? 1 : 0, + negative: inc.category.valence === 'negative' ? 1 : 0, + neutral: inc.category.valence === 'neutral' ? 1 : 0, + }); + } + } + const trendData = Array.from(trendMap.values()).sort((a, b) => a.week.localeCompare(b.week)); + + // Category distribution (pie) + const catDistMap = new Map(); + for (const inc of periodIncidents) { + const existing = catDistMap.get(inc.categoryId); + if (existing) existing.value += 1; + else catDistMap.set(inc.categoryId, { name: inc.category.name, value: 1, color: inc.category.color }); + } + const categoryDist = Array.from(catDistMap.values()); + + // Risk profile: students with >= 3 negative incidents + const riskMap = new Map(); + for (const inc of periodIncidents) { + const existing = riskMap.get(inc.studentId); + if (existing) { + existing.total += 1; + if (inc.category.valence === 'negative') existing.negative += 1; + if (inc.category.valence === 'positive') existing.positive += 1; + } else { + riskMap.set(inc.studentId, { + studentId: inc.studentId, + firstName: inc.student.firstName, + lastName: inc.student.lastName, + negative: inc.category.valence === 'negative' ? 1 : 0, + positive: inc.category.valence === 'positive' ? 1 : 0, + total: 1, + risk: 'low', + }); + } + } + const riskProfile = Array.from(riskMap.values()).map((s) => ({ + ...s, + risk: s.negative >= 5 ? 'high' : s.negative >= 3 ? 'medium' : 'low', + })).filter((s) => s.negative >= 1).sort((a, b) => b.negative - a.negative); + + // Class comparison + const classMap = new Map(); + for (const inc of periodIncidents) { + const className = inc.classGroup?.name ?? 'Unknown'; + const existing = classMap.get(className); + if (existing) { + existing.total += 1; + existing[inc.category.valence] += 1; + } else { + classMap.set(className, { + name: className, + positive: inc.category.valence === 'positive' ? 1 : 0, + negative: inc.category.valence === 'negative' ? 1 : 0, + neutral: inc.category.valence === 'neutral' ? 1 : 0, + total: 1, + }); + } + } + const classComparison = Array.from(classMap.values()).sort((a, b) => b.total - a.total); + + // Time-of-day analysis + const hourMap = new Map(); + for (const inc of periodIncidents) { + const d = new Date(inc.date); + const h = d.getHours(); + const existing = hourMap.get(h); + if (existing) existing.count += 1; + else hourMap.set(h, { hour: h, count: 1, label: `${pad(h)}:00` }); + } + const timeOfDay = Array.from(hourMap.values()).sort((a, b) => a.hour - b.hour); + + // Positive reinforcement: students with more positive than negative + const positiveReinforcement = Array.from(riskMap.values()) + .filter((s) => s.positive > s.negative && s.positive >= 2) + .sort((a, b) => (b.positive - b.negative) - (a.positive - a.negative)); + + return { + trendData, + categoryDist, + riskProfile, + classComparison, + timeOfDay, + positiveReinforcement, + periodIncidents, + }; + }, [incidents, analyticsPeriod, locale]); + /* ── Statistics computations ─────────────────────────────────────── */ const stats = useMemo(() => { const now = new Date(); @@ -829,6 +1124,25 @@ export default function BehaviorTrackingView() { {t('behavior.tab_statistics')} + + + {t('behavior.tab_analytics')} + + + + {t('behavior.tab_interventions')} + {interventions.length > 0 && ( + + {interventions.length} + + )} + {/* ── Tab: Incidents ─────────────────────────────────────────── */} @@ -1391,6 +1705,443 @@ export default function BehaviorTrackingView() { + + {/* ── Tab: Analytics ────────────────────────────────────────── */} + + + + {/* Period selector */} +
+ +
+ {(['30', '90', 'year'] as const).map((p) => ( + + ))} +
+
+ + {/* Trend Chart */} + + + +
+ +
+ {t('behavior.analytics.trend')} +
+

{t('behavior.analytics.trend_desc')}

+
+ + {analytics.trendData.length === 0 ? ( +

{t('behavior.stat.no_data')}

+ ) : ( + + + + + + + + + + + + + )} +
+
+ +
+ {/* Category Distribution */} + + + +
+ +
+ {t('behavior.analytics.category_dist')} +
+

{t('behavior.analytics.category_dist_desc')}

+
+ + {analytics.categoryDist.length === 0 ? ( +

{t('behavior.stat.no_data')}

+ ) : ( + + + `${name}: ${value}`} + labelLine={false} + > + {analytics.categoryDist.map((entry, idx) => ( + + ))} + + + + + + )} +
+
+ + {/* Time-of-Day Analysis */} + + + +
+ +
+ {t('behavior.analytics.time_of_day')} +
+

{t('behavior.analytics.time_of_day_desc')}

+
+ + {analytics.timeOfDay.length === 0 ? ( +

{t('behavior.stat.no_data')}

+ ) : ( + + + + + + + + + + )} +
+
+
+ + {/* Class Comparison */} + + + +
+ +
+ {t('behavior.analytics.class_comparison')} +
+

{t('behavior.analytics.class_comparison_desc')}

+
+ + {analytics.classComparison.length === 0 ? ( +

{t('behavior.stat.no_data')}

+ ) : ( + + + + + + + + + + + + + )} +
+
+ +
+ {/* Risk Profile */} + + + +
+ +
+ {t('behavior.analytics.risk_profile')} +
+

{t('behavior.analytics.risk_profile_desc')}

+
+ + {analytics.riskProfile.length === 0 ? ( +

{t('behavior.stat.no_data')}

+ ) : ( + +
+ {analytics.riskProfile.map((s) => { + const riskBadge = s.risk === 'high' + ? 'bg-rose-100 text-rose-700 dark:bg-rose-950/60 dark:text-rose-300' + : s.risk === 'medium' + ? 'bg-amber-100 text-amber-700 dark:bg-amber-950/60 dark:text-amber-300' + : 'bg-slate-100 text-slate-700 dark:bg-slate-950/60 dark:text-slate-300'; + return ( +
+ +
+
+ {s.firstName} {s.lastName} +
+
+ {t('behavior.analytics.incidents_count_short')}: {s.negative} neg / {s.positive} pos +
+
+ + {t(`behavior.analytics.risk_${s.risk}`)} + +
+ ); + })} +
+
+ )} +
+
+ + {/* Positive Reinforcement */} + + + +
+ +
+ {t('behavior.analytics.positive_trend')} +
+

{t('behavior.analytics.positive_trend_desc')}

+
+ + {analytics.positiveReinforcement.length === 0 ? ( +

{t('behavior.stat.no_data')}

+ ) : ( + +
+ {analytics.positiveReinforcement.map((s) => ( +
+ +
+
+ {s.firstName} {s.lastName} +
+
+ + +{s.positive - s.negative} {t('behavior.analytics.positive_change')} + +
+
+ + + {s.positive} + +
+ ))} +
+
+ )} +
+
+
+
+
+
+ + {/* ── Tab: Interventions ─────────────────────────────────────── */} + + + + {/* Header */} +
+
+ + +
+ +
+ + {/* Intervention list */} + {interventionsLoading ? ( +
+ +
+ ) : (() => { + const filtered = interventions.filter((intv) => { + if (interventionFilterStatus !== 'all' && intv.status !== interventionFilterStatus) return false; + if (interventionFilterType !== 'all' && intv.type !== interventionFilterType) return false; + return true; + }); + return filtered.length === 0 ? ( + + +
+ +
+

+ {t('behavior.intervention.no_interventions')} +

+

+ {t('behavior.intervention.no_interventions_desc')} +

+ +
+
+ ) : ( +
+ {filtered.map((intv, idx) => { + const statusCfg = INTERVENTION_STATUS_CONFIG[intv.status] ?? INTERVENTION_STATUS_CONFIG.planned; + const typeCfg = INTERVENTION_TYPE_CONFIG[intv.type] ?? { color: 'text-gray-600', icon: }; + return ( + + + +
+ +
+
+
+ {intv.student.firstName} {intv.student.lastName} +
+ + {statusCfg.icon} + {t(`behavior.intervention.status.${intv.status}`)} + +
+
+ + {typeCfg.icon} + {t(`behavior.intervention.type.${intv.type}`)} + + {intv.assignedUser && ( + + + {intv.assignedUser.firstName} {intv.assignedUser.lastName} + + )} + {intv.incident && ( + + {t('behavior.intervention.linked_incident')}: {intv.incident.description.slice(0, 20)} + + )} +
+

{intv.description}

+ + {/* Progress indicator */} +
+
+ {t('behavior.intervention.progress')} + {statusCfg.progress}% +
+ +
+ + {/* Effectiveness outcome */} + {intv.outcome && intv.status === 'completed' && ( +
+
{t('behavior.intervention.effectiveness')}
+

{intv.outcome}

+
+ )} + + {/* Actions */} +
+ + + {intv.status === 'planned' && ( + + )} + {intv.status === 'in_progress' && ( + + )} +
+
+
+
+
+
+ ); + })} +
+ ); + })()} +
+
+
{/* ── Incident Form Dialog ─────────────────────────────────────── */} @@ -1833,6 +2584,169 @@ export default function BehaviorTrackingView() { + + {/* ── Intervention Form Dialog ──────────────────────────────────── */} + { if (!o) closeInterventionDialog(); }}> + + + + + {interventionForm?.id ? t('behavior.intervention.edit') : t('behavior.intervention.add')} + + {t('behavior.intervention.subtitle')} + + + {interventionForm && ( +
+
+
+ + +
+
+ + +
+
+ +
+ +