From 4f79137d89cf0acbb242d3604ece09553b5edcbd Mon Sep 17 00:00:00 2001 From: MangoPig Date: Mon, 25 May 2026 17:05:06 +0100 Subject: [PATCH] Boost Azure Demo --- .envsitter/pepper | 1 + .gitignore | 7 +- Backend/.air.toml | 30 + Backend/Earthfile | 67 + .../cmd/backfill_historical_reviews/main.go | 560 ++ Backend/cmd/server/main.go | 81 + Backend/db/embed.go | 6 + Backend/db/migrations/001_init.sql | 160 + Backend/db/migrations/002_profiles.sql | 44 + Backend/db/migrations/003_messages.sql | 48 + .../004_student_answer_workspace.sql | 12 + .../005_question_answers_and_correctness.sql | 13 + .../006_assignment_level_feedback.sql | 53 + Backend/db/migrations/007_review_contract.sql | 66 + .../008_assignment_pass_threshold.sql | 22 + .../009_assignment_pass_status_override.sql | 9 + .../010_assignment_next_step_outcome.sql | 13 + ...ckfill_seeded_question_correct_answers.sql | 80 + ...hase_a_topics_difficulty_and_threshold.sql | 123 + .../migrations/013_redo_assignment_plan.sql | 11 + .../014_assignment_student_questions.sql | 30 + Backend/db/queries/assignments.sql | 391 + Backend/db/queries/classrooms.sql | 36 + Backend/db/queries/messages.sql | 266 + Backend/db/queries/questions.sql | 55 + Backend/db/queries/student_answers.sql | 228 + Backend/db/queries/users.sql | 178 + Backend/db/sqlc.yaml | 12 + Backend/go.mod | 33 + Backend/go.sum | 83 + Backend/internal/aireview/service.go | 469 ++ .../internal/assignmentgen/personalization.go | 106 + .../assignmentgen/personalization_plan.go | 172 + .../assignmentgen/personalization_test.go | 85 + .../assignmentgen/personalization_weakness.go | 171 + Backend/internal/assignmentgen/service.go | 91 + .../assignmentgen/service_generate.go | 244 + .../internal/assignmentgen/service_helpers.go | 89 + Backend/internal/config/config.go | 50 + Backend/internal/database/postgres.go | 65 + .../internal/handlers/api/answers/handler.go | 562 ++ .../internal/handlers/api/answers/routes.go | 16 + .../handlers/api/assignments/handler.go | 660 ++ .../api/assignments/handler_generation.go | 321 + .../api/assignments/handler_helpers.go | 375 + .../handlers/api/assignments/handler_types.go | 236 + .../handlers/api/assignments/routes.go | 25 + .../handlers/api/classrooms/handler.go | 180 + .../handlers/api/classrooms/routes.go | 14 + Backend/internal/handlers/api/handler.go | 41 + .../internal/handlers/api/messages/handler.go | 708 ++ .../internal/handlers/api/messages/routes.go | 20 + .../handlers/api/questions/handler.go | 506 ++ .../handlers/api/questions/handler_test.go | 140 + .../internal/handlers/api/questions/routes.go | 17 + Backend/internal/handlers/api/routes.go | 22 + .../internal/handlers/api/shared/shared.go | 159 + .../internal/handlers/api/users/handler.go | 133 + Backend/internal/handlers/api/users/routes.go | 13 + Backend/internal/handlers/web/auth/auth.go | 384 + .../internal/handlers/web/health/health.go | 46 + Backend/internal/handlers/web/root/root.go | 16 + Backend/internal/http/params/params.go | 21 + Backend/internal/http/respond/respond.go | 18 + Backend/internal/middleware/auth.go | 193 + Backend/internal/questiongen/service.go | 634 ++ Backend/internal/questiongen/service_test.go | 175 + Backend/internal/router/api.go | 16 + Backend/internal/router/router.go | 24 + Backend/internal/router/web.go | 32 + Backend/internal/sqlc/assignments.sql.go | 1069 +++ Backend/internal/sqlc/classrooms.sql.go | 147 + Backend/internal/sqlc/db.go | 32 + Backend/internal/sqlc/messages.sql.go | 742 ++ Backend/internal/sqlc/models.go | 526 ++ Backend/internal/sqlc/questions.sql.go | 206 + Backend/internal/sqlc/student_answers.sql.go | 649 ++ Backend/internal/sqlc/users.sql.go | 577 ++ Caddyfile | 6 +- Caddyfile.prod-a | 13 + Earthfile | 39 + Frontend/Earthfile | 33 +- .../public/brand/boost-ai-logo-purple.png | Bin 0 -> 45344 bytes Frontend/public/favicon.ico | Bin 664 -> 15406 bytes Frontend/src/app.tsx | 65 +- .../components/assignment/assignment-tabs.tsx | 27 - .../components/assignment/assignment.data.ts | 251 - .../assignment-header.module.scss | 37 +- .../{ => shared}/assignment-header.tsx | 10 +- .../assignment-overview.module.scss | 33 +- .../{ => shared}/assignment-overview.tsx | 29 +- .../shared}/assignment-page.module.scss | 14 +- .../assignment-question-list.module.scss | 17 +- .../{ => shared}/assignment-question-list.tsx | 29 +- .../assignment/shared/assignment-tabs.tsx | 39 + .../assignment/shared/assignment-types.ts | 46 + .../student/assignment-review.data.ts | 204 + .../teacher/assignment-teacher-next-step.tsx | 281 + .../assignment-teacher-redo-plan.helpers.ts | 107 + .../assignment-teacher-redo-plan.sections.tsx | 177 + .../teacher/assignment-teacher-redo-plan.tsx | 59 + .../assignment-teacher-redo-plan.types.ts | 18 + .../teacher/assignment-teacher-review.data.ts | 246 + .../assignment-teacher-review.drafts.ts | 116 + .../assignment-teacher-review.formatters.ts | 194 + .../assignment-teacher-review.module.scss | 824 ++ .../assignment-teacher-review.sections.tsx | 388 + .../teacher/assignment-teacher-review.tsx | 354 + .../assignment-teacher-review.types.ts | 130 + .../assignment/work/assignment-work.data.ts | 215 + .../work}/assignment-work.module.scss | 200 +- .../work/student-assignment-work.page.tsx | 273 + .../work/student-assignment-work.sections.tsx | 247 + .../dashboard/dashboard-assignments-focus.tsx | 76 - .../dashboard-messages-focus.module.scss | 188 - .../dashboard/dashboard-messages-focus.tsx | 62 - .../dashboard/dashboard-settings-focus.tsx | 77 - .../dashboard/dashboard-sidebar.tsx | 62 - .../components/dashboard/dashboard-topbar.tsx | 218 - .../components/dashboard/dashboard.data.ts | 774 -- .../messages/dashboard-messages.data.ts | 232 + .../messages/dashboard-messages.module.scss | 551 ++ .../messages/dashboard-messages.page.tsx | 353 + .../messages/dashboard-messages.sections.tsx | 315 + .../dashboard-settings-focus.module.scss | 148 +- .../settings/dashboard-settings-focus.tsx | 255 + .../{ => shared}/dashboard-shell.tsx | 36 +- .../dashboard-sidebar.module.scss | 78 +- .../dashboard/shared/dashboard-sidebar.tsx | 64 + .../dashboard-theme-toggle.module.scss | 6 +- .../{ => shared}/dashboard-theme-toggle.tsx | 4 +- .../{ => shared}/dashboard-topbar.module.scss | 218 +- .../dashboard/shared/dashboard-topbar.tsx | 286 + .../dashboard/shared/dashboard-types.ts | 156 + .../dashboard/shared/dashboard.data.ts | 210 + .../dashboard-activity.module.scss | 12 +- .../{ => student}/dashboard-activity.tsx | 23 +- .../dashboard-assignments-focus.module.scss | 27 +- .../student/dashboard-assignments-focus.tsx | 94 + .../student/dashboard-assignments.data.ts | 169 + .../dashboard-courses.module.scss | 22 +- .../{ => student}/dashboard-courses.tsx | 15 +- .../{ => student}/dashboard-hero.module.scss | 30 +- .../{ => student}/dashboard-hero.tsx | 26 +- .../dashboard/student/dashboard-home.data.ts | 321 + .../student/dashboard-home.helpers.ts | 232 + .../dashboard-insights.module.scss | 22 +- .../{ => student}/dashboard-insights.tsx | 22 +- .../dashboard-instructors.module.scss | 10 +- .../{ => student}/dashboard-instructors.tsx | 13 +- .../dashboard-practice-focus.module.scss | 16 +- .../dashboard-practice-focus.tsx | 16 +- .../student/dashboard-practice.data.ts | 98 + .../dashboard-progress-focus.module.scss | 12 +- .../dashboard-progress-focus.tsx | 18 +- .../student/dashboard-progress.data.ts | 52 + ...ard-teacher-assignment-create.constants.ts | 43 + ...board-teacher-assignment-create.helpers.ts | 37 + ...ard-teacher-assignment-create.sections.tsx | 341 + .../dashboard-teacher-assignment-create.tsx | 240 + ...shboard-teacher-assignment-create.types.ts | 29 + .../dashboard-teacher-assignments.data.ts | 289 + .../dashboard-teacher-assignments.helpers.ts | 156 + .../dashboard-teacher-assignments.module.scss | 674 ++ .../teacher/dashboard-teacher-assignments.tsx | 163 + .../dashboard-teacher-assignments.types.ts | 90 + ...dashboard-teacher-classroom-detail.data.ts | 152 + ...hboard-teacher-classroom-detail.helpers.ts | 107 + .../dashboard-teacher-classroom-detail.tsx | 131 + ...ashboard-teacher-classroom-detail.types.ts | 52 + .../dashboard-teacher-classrooms.module.scss | 341 + .../teacher/dashboard-teacher-classrooms.tsx | 67 + .../teacher/dashboard-teacher-home.data.ts | 341 + .../dashboard-teacher-home.module.scss | 478 ++ .../teacher/dashboard-teacher-home.tsx | 133 + Frontend/src/content/dashboard-labels.ts | 64 + Frontend/src/content/ui-copy.ts | 232 + Frontend/src/context/auth/context.tsx | 191 + Frontend/src/entry-server.tsx | 15 +- Frontend/src/lib/api-types.ts | 185 + Frontend/src/lib/api.ts | 58 + Frontend/src/lib/routes.ts | 46 + Frontend/src/routes/assignment/[id].tsx | 9 - Frontend/src/routes/assignment/[id]/index.tsx | 48 - Frontend/src/routes/assignment/[id]/work.tsx | 382 - .../routes/assignments/student/[id]/index.tsx | 77 + .../routes/assignments/student/[id]/work.tsx | 3 + .../routes/assignments/teacher/[id]/index.tsx | 82 + .../assignments/teacher/[id]/next-step.tsx | 70 + .../assignments/teacher/[id]/redo-plan.tsx | 71 + Frontend/src/routes/auth/login.module.scss | 282 +- Frontend/src/routes/auth/login.tsx | 93 +- Frontend/src/routes/auth/signup.tsx | 48 +- Frontend/src/routes/dashboard/assignments.tsx | 22 +- .../routes/dashboard/dashboard.module.scss | 68 +- Frontend/src/routes/dashboard/index.tsx | 35 +- Frontend/src/routes/dashboard/messages.tsx | 31 +- Frontend/src/routes/dashboard/practice.tsx | 23 +- Frontend/src/routes/dashboard/progress.tsx | 23 +- Frontend/src/routes/dashboard/settings.tsx | 22 +- .../routes/dashboard/student/assignments.tsx | 24 + .../src/routes/dashboard/student/index.tsx | 46 + .../src/routes/dashboard/student/messages.tsx | 30 + .../src/routes/dashboard/student/practice.tsx | 29 + .../src/routes/dashboard/student/progress.tsx | 29 + .../src/routes/dashboard/student/settings.tsx | 24 + .../dashboard/teacher/assignment/create.tsx | 24 + .../dashboard/teacher/assignments/closed.tsx | 24 + .../dashboard/teacher/assignments/index.tsx | 24 + .../dashboard/teacher/classrooms/[id].tsx | 57 + .../dashboard/teacher/classrooms/index.tsx | 29 + .../src/routes/dashboard/teacher/index.tsx | 29 + .../src/routes/dashboard/teacher/messages.tsx | 30 + .../src/routes/dashboard/teacher/settings.tsx | 24 + Frontend/src/routes/index.tsx | 38 +- Frontend/src/routes/landing.module.scss | 197 +- Frontend/src/styles/main.scss | 26 - Frontend/src/styles/vars.scss | 92 +- Frontend/vite.config.ts | 3 + Makefile | 53 +- Mock-Data/README.md | 15 + Mock-Data/assignment_assignees.json | 231 + Mock-Data/bonus_early_warning_input.json | 485 ++ Mock-Data/bonus_early_warning_output.json | 337 + Mock-Data/dataset.json | 6823 +++++++++++++++++ Mock-Data/generate.py | 178 + Mock-Data/student_answers.json | 6592 ++++++++++++++++ TODO.md | 1 + docker-compose.dev.yaml | 44 + docker-compose.prod-a.yaml | 82 + 230 files changed, 43275 insertions(+), 2644 deletions(-) create mode 100644 .envsitter/pepper create mode 100644 Backend/.air.toml create mode 100644 Backend/Earthfile create mode 100644 Backend/cmd/backfill_historical_reviews/main.go create mode 100644 Backend/cmd/server/main.go create mode 100644 Backend/db/embed.go create mode 100644 Backend/db/migrations/001_init.sql create mode 100644 Backend/db/migrations/002_profiles.sql create mode 100644 Backend/db/migrations/003_messages.sql create mode 100644 Backend/db/migrations/004_student_answer_workspace.sql create mode 100644 Backend/db/migrations/005_question_answers_and_correctness.sql create mode 100644 Backend/db/migrations/006_assignment_level_feedback.sql create mode 100644 Backend/db/migrations/007_review_contract.sql create mode 100644 Backend/db/migrations/008_assignment_pass_threshold.sql create mode 100644 Backend/db/migrations/009_assignment_pass_status_override.sql create mode 100644 Backend/db/migrations/010_assignment_next_step_outcome.sql create mode 100644 Backend/db/migrations/011_backfill_seeded_question_correct_answers.sql create mode 100644 Backend/db/migrations/012_phase_a_topics_difficulty_and_threshold.sql create mode 100644 Backend/db/migrations/013_redo_assignment_plan.sql create mode 100644 Backend/db/migrations/014_assignment_student_questions.sql create mode 100644 Backend/db/queries/assignments.sql create mode 100644 Backend/db/queries/classrooms.sql create mode 100644 Backend/db/queries/messages.sql create mode 100644 Backend/db/queries/questions.sql create mode 100644 Backend/db/queries/student_answers.sql create mode 100644 Backend/db/queries/users.sql create mode 100644 Backend/db/sqlc.yaml create mode 100644 Backend/go.mod create mode 100644 Backend/go.sum create mode 100644 Backend/internal/aireview/service.go create mode 100644 Backend/internal/assignmentgen/personalization.go create mode 100644 Backend/internal/assignmentgen/personalization_plan.go create mode 100644 Backend/internal/assignmentgen/personalization_test.go create mode 100644 Backend/internal/assignmentgen/personalization_weakness.go create mode 100644 Backend/internal/assignmentgen/service.go create mode 100644 Backend/internal/assignmentgen/service_generate.go create mode 100644 Backend/internal/assignmentgen/service_helpers.go create mode 100644 Backend/internal/config/config.go create mode 100644 Backend/internal/database/postgres.go create mode 100644 Backend/internal/handlers/api/answers/handler.go create mode 100644 Backend/internal/handlers/api/answers/routes.go create mode 100644 Backend/internal/handlers/api/assignments/handler.go create mode 100644 Backend/internal/handlers/api/assignments/handler_generation.go create mode 100644 Backend/internal/handlers/api/assignments/handler_helpers.go create mode 100644 Backend/internal/handlers/api/assignments/handler_types.go create mode 100644 Backend/internal/handlers/api/assignments/routes.go create mode 100644 Backend/internal/handlers/api/classrooms/handler.go create mode 100644 Backend/internal/handlers/api/classrooms/routes.go create mode 100644 Backend/internal/handlers/api/handler.go create mode 100644 Backend/internal/handlers/api/messages/handler.go create mode 100644 Backend/internal/handlers/api/messages/routes.go create mode 100644 Backend/internal/handlers/api/questions/handler.go create mode 100644 Backend/internal/handlers/api/questions/handler_test.go create mode 100644 Backend/internal/handlers/api/questions/routes.go create mode 100644 Backend/internal/handlers/api/routes.go create mode 100644 Backend/internal/handlers/api/shared/shared.go create mode 100644 Backend/internal/handlers/api/users/handler.go create mode 100644 Backend/internal/handlers/api/users/routes.go create mode 100644 Backend/internal/handlers/web/auth/auth.go create mode 100644 Backend/internal/handlers/web/health/health.go create mode 100644 Backend/internal/handlers/web/root/root.go create mode 100644 Backend/internal/http/params/params.go create mode 100644 Backend/internal/http/respond/respond.go create mode 100644 Backend/internal/middleware/auth.go create mode 100644 Backend/internal/questiongen/service.go create mode 100644 Backend/internal/questiongen/service_test.go create mode 100644 Backend/internal/router/api.go create mode 100644 Backend/internal/router/router.go create mode 100644 Backend/internal/router/web.go create mode 100644 Backend/internal/sqlc/assignments.sql.go create mode 100644 Backend/internal/sqlc/classrooms.sql.go create mode 100644 Backend/internal/sqlc/db.go create mode 100644 Backend/internal/sqlc/messages.sql.go create mode 100644 Backend/internal/sqlc/models.go create mode 100644 Backend/internal/sqlc/questions.sql.go create mode 100644 Backend/internal/sqlc/student_answers.sql.go create mode 100644 Backend/internal/sqlc/users.sql.go create mode 100644 Caddyfile.prod-a create mode 100644 Earthfile create mode 100644 Frontend/public/brand/boost-ai-logo-purple.png delete mode 100644 Frontend/src/components/assignment/assignment-tabs.tsx delete mode 100644 Frontend/src/components/assignment/assignment.data.ts rename Frontend/src/components/assignment/{ => shared}/assignment-header.module.scss (65%) rename Frontend/src/components/assignment/{ => shared}/assignment-header.tsx (70%) rename Frontend/src/components/assignment/{ => shared}/assignment-overview.module.scss (66%) rename Frontend/src/components/assignment/{ => shared}/assignment-overview.tsx (58%) rename Frontend/src/{routes/assignment => components/assignment/shared}/assignment-page.module.scss (89%) rename Frontend/src/components/assignment/{ => shared}/assignment-question-list.module.scss (87%) rename Frontend/src/components/assignment/{ => shared}/assignment-question-list.tsx (65%) create mode 100644 Frontend/src/components/assignment/shared/assignment-tabs.tsx create mode 100644 Frontend/src/components/assignment/shared/assignment-types.ts create mode 100644 Frontend/src/components/assignment/student/assignment-review.data.ts create mode 100644 Frontend/src/components/assignment/teacher/assignment-teacher-next-step.tsx create mode 100644 Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.helpers.ts create mode 100644 Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.sections.tsx create mode 100644 Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.tsx create mode 100644 Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.types.ts create mode 100644 Frontend/src/components/assignment/teacher/assignment-teacher-review.data.ts create mode 100644 Frontend/src/components/assignment/teacher/assignment-teacher-review.drafts.ts create mode 100644 Frontend/src/components/assignment/teacher/assignment-teacher-review.formatters.ts create mode 100644 Frontend/src/components/assignment/teacher/assignment-teacher-review.module.scss create mode 100644 Frontend/src/components/assignment/teacher/assignment-teacher-review.sections.tsx create mode 100644 Frontend/src/components/assignment/teacher/assignment-teacher-review.tsx create mode 100644 Frontend/src/components/assignment/teacher/assignment-teacher-review.types.ts create mode 100644 Frontend/src/components/assignment/work/assignment-work.data.ts rename Frontend/src/{routes/assignment/[id] => components/assignment/work}/assignment-work.module.scss (67%) create mode 100644 Frontend/src/components/assignment/work/student-assignment-work.page.tsx create mode 100644 Frontend/src/components/assignment/work/student-assignment-work.sections.tsx delete mode 100644 Frontend/src/components/dashboard/dashboard-assignments-focus.tsx delete mode 100644 Frontend/src/components/dashboard/dashboard-messages-focus.module.scss delete mode 100644 Frontend/src/components/dashboard/dashboard-messages-focus.tsx delete mode 100644 Frontend/src/components/dashboard/dashboard-settings-focus.tsx delete mode 100644 Frontend/src/components/dashboard/dashboard-sidebar.tsx delete mode 100644 Frontend/src/components/dashboard/dashboard-topbar.tsx delete mode 100644 Frontend/src/components/dashboard/dashboard.data.ts create mode 100644 Frontend/src/components/dashboard/messages/dashboard-messages.data.ts create mode 100644 Frontend/src/components/dashboard/messages/dashboard-messages.module.scss create mode 100644 Frontend/src/components/dashboard/messages/dashboard-messages.page.tsx create mode 100644 Frontend/src/components/dashboard/messages/dashboard-messages.sections.tsx rename Frontend/src/components/dashboard/{ => settings}/dashboard-settings-focus.module.scss (59%) create mode 100644 Frontend/src/components/dashboard/settings/dashboard-settings-focus.tsx rename Frontend/src/components/dashboard/{ => shared}/dashboard-shell.tsx (60%) rename Frontend/src/components/dashboard/{ => shared}/dashboard-sidebar.module.scss (74%) create mode 100644 Frontend/src/components/dashboard/shared/dashboard-sidebar.tsx rename Frontend/src/components/dashboard/{ => shared}/dashboard-theme-toggle.module.scss (88%) rename Frontend/src/components/dashboard/{ => shared}/dashboard-theme-toggle.tsx (87%) rename Frontend/src/components/dashboard/{ => shared}/dashboard-topbar.module.scss (67%) create mode 100644 Frontend/src/components/dashboard/shared/dashboard-topbar.tsx create mode 100644 Frontend/src/components/dashboard/shared/dashboard-types.ts create mode 100644 Frontend/src/components/dashboard/shared/dashboard.data.ts rename Frontend/src/components/dashboard/{ => student}/dashboard-activity.module.scss (91%) rename Frontend/src/components/dashboard/{ => student}/dashboard-activity.tsx (62%) rename Frontend/src/components/dashboard/{ => student}/dashboard-assignments-focus.module.scss (88%) create mode 100644 Frontend/src/components/dashboard/student/dashboard-assignments-focus.tsx create mode 100644 Frontend/src/components/dashboard/student/dashboard-assignments.data.ts rename Frontend/src/components/dashboard/{ => student}/dashboard-courses.module.scss (80%) rename Frontend/src/components/dashboard/{ => student}/dashboard-courses.tsx (64%) rename Frontend/src/components/dashboard/{ => student}/dashboard-hero.module.scss (88%) rename Frontend/src/components/dashboard/{ => student}/dashboard-hero.tsx (60%) create mode 100644 Frontend/src/components/dashboard/student/dashboard-home.data.ts create mode 100644 Frontend/src/components/dashboard/student/dashboard-home.helpers.ts rename Frontend/src/components/dashboard/{ => student}/dashboard-insights.module.scss (85%) rename Frontend/src/components/dashboard/{ => student}/dashboard-insights.tsx (67%) rename Frontend/src/components/dashboard/{ => student}/dashboard-instructors.module.scss (90%) rename Frontend/src/components/dashboard/{ => student}/dashboard-instructors.tsx (71%) rename Frontend/src/components/dashboard/{ => student}/dashboard-practice-focus.module.scss (92%) rename Frontend/src/components/dashboard/{ => student}/dashboard-practice-focus.tsx (73%) create mode 100644 Frontend/src/components/dashboard/student/dashboard-practice.data.ts rename Frontend/src/components/dashboard/{ => student}/dashboard-progress-focus.module.scss (87%) rename Frontend/src/components/dashboard/{ => student}/dashboard-progress-focus.tsx (60%) create mode 100644 Frontend/src/components/dashboard/student/dashboard-progress.data.ts create mode 100644 Frontend/src/components/dashboard/teacher/dashboard-teacher-assignment-create.constants.ts create mode 100644 Frontend/src/components/dashboard/teacher/dashboard-teacher-assignment-create.helpers.ts create mode 100644 Frontend/src/components/dashboard/teacher/dashboard-teacher-assignment-create.sections.tsx create mode 100644 Frontend/src/components/dashboard/teacher/dashboard-teacher-assignment-create.tsx create mode 100644 Frontend/src/components/dashboard/teacher/dashboard-teacher-assignment-create.types.ts create mode 100644 Frontend/src/components/dashboard/teacher/dashboard-teacher-assignments.data.ts create mode 100644 Frontend/src/components/dashboard/teacher/dashboard-teacher-assignments.helpers.ts create mode 100644 Frontend/src/components/dashboard/teacher/dashboard-teacher-assignments.module.scss create mode 100644 Frontend/src/components/dashboard/teacher/dashboard-teacher-assignments.tsx create mode 100644 Frontend/src/components/dashboard/teacher/dashboard-teacher-assignments.types.ts create mode 100644 Frontend/src/components/dashboard/teacher/dashboard-teacher-classroom-detail.data.ts create mode 100644 Frontend/src/components/dashboard/teacher/dashboard-teacher-classroom-detail.helpers.ts create mode 100644 Frontend/src/components/dashboard/teacher/dashboard-teacher-classroom-detail.tsx create mode 100644 Frontend/src/components/dashboard/teacher/dashboard-teacher-classroom-detail.types.ts create mode 100644 Frontend/src/components/dashboard/teacher/dashboard-teacher-classrooms.module.scss create mode 100644 Frontend/src/components/dashboard/teacher/dashboard-teacher-classrooms.tsx create mode 100644 Frontend/src/components/dashboard/teacher/dashboard-teacher-home.data.ts create mode 100644 Frontend/src/components/dashboard/teacher/dashboard-teacher-home.module.scss create mode 100644 Frontend/src/components/dashboard/teacher/dashboard-teacher-home.tsx create mode 100644 Frontend/src/content/dashboard-labels.ts create mode 100644 Frontend/src/content/ui-copy.ts create mode 100644 Frontend/src/context/auth/context.tsx create mode 100644 Frontend/src/lib/api-types.ts create mode 100644 Frontend/src/lib/api.ts create mode 100644 Frontend/src/lib/routes.ts delete mode 100644 Frontend/src/routes/assignment/[id].tsx delete mode 100644 Frontend/src/routes/assignment/[id]/index.tsx delete mode 100644 Frontend/src/routes/assignment/[id]/work.tsx create mode 100644 Frontend/src/routes/assignments/student/[id]/index.tsx create mode 100644 Frontend/src/routes/assignments/student/[id]/work.tsx create mode 100644 Frontend/src/routes/assignments/teacher/[id]/index.tsx create mode 100644 Frontend/src/routes/assignments/teacher/[id]/next-step.tsx create mode 100644 Frontend/src/routes/assignments/teacher/[id]/redo-plan.tsx create mode 100644 Frontend/src/routes/dashboard/student/assignments.tsx create mode 100644 Frontend/src/routes/dashboard/student/index.tsx create mode 100644 Frontend/src/routes/dashboard/student/messages.tsx create mode 100644 Frontend/src/routes/dashboard/student/practice.tsx create mode 100644 Frontend/src/routes/dashboard/student/progress.tsx create mode 100644 Frontend/src/routes/dashboard/student/settings.tsx create mode 100644 Frontend/src/routes/dashboard/teacher/assignment/create.tsx create mode 100644 Frontend/src/routes/dashboard/teacher/assignments/closed.tsx create mode 100644 Frontend/src/routes/dashboard/teacher/assignments/index.tsx create mode 100644 Frontend/src/routes/dashboard/teacher/classrooms/[id].tsx create mode 100644 Frontend/src/routes/dashboard/teacher/classrooms/index.tsx create mode 100644 Frontend/src/routes/dashboard/teacher/index.tsx create mode 100644 Frontend/src/routes/dashboard/teacher/messages.tsx create mode 100644 Frontend/src/routes/dashboard/teacher/settings.tsx create mode 100644 Mock-Data/bonus_early_warning_input.json create mode 100644 Mock-Data/bonus_early_warning_output.json create mode 100644 TODO.md create mode 100644 docker-compose.prod-a.yaml diff --git a/.envsitter/pepper b/.envsitter/pepper new file mode 100644 index 0000000..8de9c6e --- /dev/null +++ b/.envsitter/pepper @@ -0,0 +1 @@ +RgIlJyE1N29vsJg2hyEPwkyf4Fkf7vWFNZggxti97pI= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7bdd2d7..1ba8a39 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ node_modules .output .nitro -dist \ No newline at end of file +dist +tmp +seed +.env +.env.* +!.env.example diff --git a/Backend/.air.toml b/Backend/.air.toml new file mode 100644 index 0000000..9057af7 --- /dev/null +++ b/Backend/.air.toml @@ -0,0 +1,30 @@ +root = "." +tmp_dir = "tmp" + +[build] +bin = "./tmp/main" +cmd = "go build -o ./tmp/main ./cmd/server" +delay = 1000 +exclude_dir = ["tmp", "vendor"] +exclude_regex = ["_test.go"] +follow_symlink = false +include_ext = ["go"] +kill_delay = "0s" +log = "build-errors.log" +poll = false +rerun = false +rerun_delay = 500 +send_interrupt = false +stop_on_error = false + +[log] +main_only = false +silent = false +time = false + +[misc] +clean_on_exit = true + +[screen] +clear_on_rebuild = true +keep_scroll = true diff --git a/Backend/Earthfile b/Backend/Earthfile new file mode 100644 index 0000000..54fe54b --- /dev/null +++ b/Backend/Earthfile @@ -0,0 +1,67 @@ +VERSION 0.8 + +go-base: + FROM golang:1.24.11-bookworm + RUN apt-get update && apt-get install -y git curl && rm -rf /var/lib/apt/lists/* + RUN curl -sSfL https://raw.githubusercontent.com/air-verse/air/master/install.sh | sh -s -- -b $(go env GOPATH)/bin + RUN go install github.com/pressly/goose/v3/cmd/goose@v3.26.0 + RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0 + WORKDIR /app + COPY go.mod go.sum ./ + +deps: + FROM +go-base + RUN go mod download + +dev-image: + ARG IMAGE_NAME="boost-ai/demo-backend-dev" + ARG TAG="latest" + + FROM +deps + COPY . . + + ENV GO_ENV=development + ENV BACKEND_INTERNAL_PORT=8081 + ENV DATABASE_URL=postgres://boostai:boostai_dev_password@postgres-dev:5432/boostai?sslmode=disable + EXPOSE 8081 + + SAVE IMAGE $IMAGE_NAME:$TAG + +dev-image-push: + ARG REGISTRY="registry.mangopig.tech" + ARG IMAGE_NAME="boost-ai/demo-backend-dev" + ARG TAG="latest" + + FROM +dev-image --IMAGE_NAME=$IMAGE_NAME --TAG=$TAG + SAVE IMAGE --push $REGISTRY/$IMAGE_NAME:$TAG + +prod-bin: + FROM +deps + COPY . . + RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/boostai-server ./cmd/server + SAVE ARTIFACT /out/boostai-server AS LOCAL ./tmp/prod-a/boostai-server + +prod-image: + ARG IMAGE_NAME="boost-ai/demo-backend-prod-a" + ARG TAG="latest" + + FROM alpine:3.22 + RUN apk add --no-cache ca-certificates + WORKDIR /app + COPY +prod-bin/boostai-server /app/boostai-server + + ENV GO_ENV=production + ENV BACKEND_INTERNAL_PORT=8081 + EXPOSE 8081 + + ENTRYPOINT ["/app/boostai-server"] + + SAVE IMAGE $IMAGE_NAME:$TAG + +prod-image-push: + ARG REGISTRY="registry.mangopig.tech" + ARG IMAGE_NAME="boost-ai/demo-backend-prod-a" + ARG TAG="latest" + + FROM +prod-image --IMAGE_NAME=$IMAGE_NAME --TAG=$TAG + SAVE IMAGE --push $REGISTRY/$IMAGE_NAME:$TAG diff --git a/Backend/cmd/backfill_historical_reviews/main.go b/Backend/cmd/backfill_historical_reviews/main.go new file mode 100644 index 0000000..8bc21f8 --- /dev/null +++ b/Backend/cmd/backfill_historical_reviews/main.go @@ -0,0 +1,560 @@ +package main + +import ( + "boostai-backend/internal/config" + "boostai-backend/internal/database" + "context" + "encoding/json" + "errors" + "fmt" + "log" + "math" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +var historicalAssignmentIDs = map[int64]bool{ + 3001: true, + 3002: true, + 3003: true, + 3004: true, + 3005: true, +} + +type assignmentRecord struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + MaximumMarks int `json:"maximum_marks"` +} + +type assigneeRecord struct { + ID int64 `json:"id"` + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + Status string `json:"status"` + OverallScore *float64 `json:"overall_score"` + AiFeedback *string `json:"ai_feedback"` + NextStepOutcome *string `json:"next_step_outcome"` +} + +type assignmentQuestionRecord struct { + ID int64 `json:"id"` + AssignmentID int64 `json:"assignment_id"` + QuestionBankID int64 `json:"question_bank_id"` +} + +type studentAnswerRecord struct { + AssigneeID int64 `json:"assignee_id"` + AssignmentQuestionID int64 `json:"assignment_question_id"` + AnswerLatex string `json:"answer_latex"` + AiReasoning string `json:"ai_reasoning"` + AiFeedback *string `json:"ai_feedback"` + SolveMode *string `json:"solve_mode"` + UnderSolveMode *string `json:"_solve_mode"` + IsCorrect *bool `json:"is_correct"` + UnderIsCorrect *bool `json:"_is_correct"` + MisconceptionTag *string `json:"_misconception_tag"` + QuestionTopic *string `json:"_question_topic"` + ReviewNeedsAttention *bool `json:"review_needs_attention"` + ReviewIssueReason *string `json:"review_issue_reason"` + ReviewUnderstandingScore *float64 `json:"review_understanding_score"` + ReviewCorrectnessScore *float64 `json:"review_correctness_score"` + ReviewQuestionScore *float64 `json:"review_question_score"` + ReviewConfidence *float64 `json:"review_confidence"` + ReviewTags []string `json:"review_tags"` + CreatedAt int64 `json:"created_at"` + AnsweredAt *int64 `json:"_answered_at"` +} + +type questionReviewUpdate struct { + AssignmentID int64 + StudentID int64 + QuestionID int64 + IsCorrect bool + AIFeedback string + NeedsAttention bool + IssueReason string + CorrectnessScore float64 + UnderstandingScore float64 + QuestionScore float64 + Confidence float64 + ReviewTags []string + Topic string + QuestionContribution float64 + AnsweredAt time.Time + HasAnsweredAt bool +} + +type assigneeSummary struct { + AssignmentID int64 + StudentID int64 + AssignmentName string + OverallScore *float64 + AIFeedback string + NextStepOutcome string + QuestionUpdates []questionReviewUpdate + CorrectCount int + NeedsAttentionCnt int +} + +func main() { + cfg := config.Load() + db, err := database.NewPostgres(cfg.DatabaseURL) + if err != nil { + log.Fatalf("failed to connect to database: %v", err) + } + defer db.Close() + + mockDataDir, err := resolveMockDataDir() + if err != nil { + log.Fatalf("failed to resolve mock data directory: %v", err) + } + + assignments, err := readJSON[[]assignmentRecord](filepath.Join(mockDataDir, "assignments.json")) + if err != nil { + log.Fatalf("failed to read assignments.json: %v", err) + } + assignees, err := readJSON[[]assigneeRecord](filepath.Join(mockDataDir, "assignment_assignees.json")) + if err != nil { + log.Fatalf("failed to read assignment_assignees.json: %v", err) + } + assignmentQuestions, err := readJSON[[]assignmentQuestionRecord](filepath.Join(mockDataDir, "assignment_questions.json")) + if err != nil { + log.Fatalf("failed to read assignment_questions.json: %v", err) + } + studentAnswers, err := readJSON[[]studentAnswerRecord](filepath.Join(mockDataDir, "student_answers.json")) + if err != nil { + log.Fatalf("failed to read student_answers.json: %v", err) + } + + assignmentByID := map[int64]assignmentRecord{} + for _, item := range assignments { + assignmentByID[item.ID] = item + } + + assigneeByID := map[int64]assigneeRecord{} + for _, item := range assignees { + assigneeByID[item.ID] = item + } + + questionIDByAssignmentQuestionID := map[int64]int64{} + for _, item := range assignmentQuestions { + questionIDByAssignmentQuestionID[item.ID] = item.QuestionBankID + } + + summaries := map[string]*assigneeSummary{} + for _, row := range studentAnswers { + assignee, ok := assigneeByID[row.AssigneeID] + if !ok || !historicalAssignmentIDs[assignee.AssignmentID] { + continue + } + questionID, ok := questionIDByAssignmentQuestionID[row.AssignmentQuestionID] + if !ok { + continue + } + assignment := assignmentByID[assignee.AssignmentID] + key := fmt.Sprintf("%d:%d", assignee.AssignmentID, assignee.StudentID) + summary := summaries[key] + if summary == nil { + summary = &assigneeSummary{ + AssignmentID: assignee.AssignmentID, + StudentID: assignee.StudentID, + AssignmentName: assignment.Name, + } + summaries[key] = summary + } + + update := buildQuestionReviewUpdate(assignee, questionID, row) + summary.QuestionUpdates = append(summary.QuestionUpdates, update) + if update.IsCorrect { + summary.CorrectCount++ + } + if update.NeedsAttention { + summary.NeedsAttentionCnt++ + } + } + + for _, summary := range summaries { + finalizeAssigneeSummary(summary) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + tx, err := db.Pool.Begin(ctx) + if err != nil { + log.Fatalf("failed to begin transaction: %v", err) + } + defer tx.Rollback(ctx) + + updatedAnswers := 0 + updatedAssignees := 0 + for _, summary := range summaries { + for _, update := range summary.QuestionUpdates { + _, err := tx.Exec(ctx, ` + UPDATE student_answers + SET is_correct = $4, + ai_feedback = $5, + review_needs_attention = $6, + review_issue_reason = $7, + review_correctness_score = $8, + review_understanding_score = $9, + review_question_score = $10, + review_confidence = $11, + review_tags = $12, + updated_at = NOW() + WHERE assignment_id = $1 + AND student_id = $2 + AND question_id = $3 + `, + update.AssignmentID, + update.StudentID, + update.QuestionID, + update.IsCorrect, + nullableString(update.AIFeedback), + update.NeedsAttention, + nullableString(update.IssueReason), + update.CorrectnessScore, + update.UnderstandingScore, + update.QuestionScore, + update.Confidence, + update.ReviewTags, + ) + if err != nil { + log.Fatalf("failed to update student answer (%d/%d/%d): %v", update.AssignmentID, update.StudentID, update.QuestionID, err) + } + updatedAnswers++ + } + + var overall any + var passStatus string + if summary.OverallScore == nil { + passStatus = "pending" + overall = nil + } else { + overall = *summary.OverallScore + if *summary.OverallScore >= 6.0 { + passStatus = "pass" + } else { + passStatus = "no_pass" + } + } + + _, err := tx.Exec(ctx, ` + UPDATE assignment_assignees + SET overall_score = $3, + ai_feedback = $4, + pass_status = $5, + pass_status_override = NULL + WHERE assignment_id = $1 + AND student_id = $2 + `, summary.AssignmentID, summary.StudentID, overall, nullableString(summary.AIFeedback), passStatus) + if err != nil { + log.Fatalf("failed to update assignment assignee (%d/%d): %v", summary.AssignmentID, summary.StudentID, err) + } + updatedAssignees++ + } + + if err := tx.Commit(ctx); err != nil { + log.Fatalf("failed to commit transaction: %v", err) + } + + log.Printf("historical review backfill complete: %d answers updated, %d assignees updated", updatedAnswers, updatedAssignees) +} + +func buildQuestionReviewUpdate(assignee assigneeRecord, questionID int64, row studentAnswerRecord) questionReviewUpdate { + isCorrect := false + if row.IsCorrect != nil { + isCorrect = *row.IsCorrect + } else if row.UnderIsCorrect != nil { + isCorrect = *row.UnderIsCorrect + } else { + isCorrect = !strings.HasPrefix(strings.ToLower(strings.TrimSpace(row.AiReasoning)), "incorrect") + } + + solveMode := firstNonEmptyString(row.SolveMode, row.UnderSolveMode) + understanding := valueOrElse(row.ReviewUnderstandingScore, deriveUnderstandingScore(isCorrect, solveMode)) + confidence := valueOrElse(row.ReviewConfidence, deriveConfidenceScore(isCorrect, solveMode)) + needsAttention := boolOrElse(row.ReviewNeedsAttention, !isCorrect || understanding < 0.72) + issueReason := stringOrElse(row.ReviewIssueReason, deriveIssueReason(row.AiReasoning, row.MisconceptionTag)) + aiFeedback := stringOrElse(row.AiFeedback, row.AiReasoning) + reviewTags := sanitizeTags(row.ReviewTags, row.MisconceptionTag) + correctness := valueOrElse(row.ReviewCorrectnessScore, 1.0) + questionScore := valueOrElse(row.ReviewQuestionScore, 1.0) + questionContribution := ((boolToFloat(isCorrect)) + understanding) / 2 + + var answeredAt time.Time + var hasAnsweredAt bool + if row.AnsweredAt != nil && *row.AnsweredAt > 0 { + answeredAt = time.UnixMilli(*row.AnsweredAt).UTC() + hasAnsweredAt = true + } else if row.CreatedAt > 0 { + answeredAt = time.UnixMilli(row.CreatedAt).UTC() + hasAnsweredAt = true + } + + return questionReviewUpdate{ + AssignmentID: assignee.AssignmentID, + StudentID: assignee.StudentID, + QuestionID: questionID, + IsCorrect: isCorrect, + AIFeedback: aiFeedback, + NeedsAttention: needsAttention, + IssueReason: issueReason, + CorrectnessScore: roundToThree(correctness), + UnderstandingScore: roundToThree(understanding), + QuestionScore: roundToThree(questionScore), + Confidence: roundToThree(confidence), + ReviewTags: reviewTags, + Topic: stringOrElse(row.QuestionTopic, "general"), + QuestionContribution: questionContribution, + AnsweredAt: answeredAt, + HasAnsweredAt: hasAnsweredAt, + } +} + +func finalizeAssigneeSummary(summary *assigneeSummary) { + if len(summary.QuestionUpdates) == 0 { + return + } + var total float64 + topicScores := map[string][]float64{} + for _, item := range summary.QuestionUpdates { + total += item.QuestionContribution + topicScores[item.Topic] = append(topicScores[item.Topic], item.QuestionContribution) + } + overall := roundToTwo(total / float64(len(summary.QuestionUpdates)) * 10) + summary.OverallScore = &overall + + type topicAvg struct { + name string + avg float64 + } + var weakest []topicAvg + for topic, scores := range topicScores { + var subtotal float64 + for _, score := range scores { + subtotal += score + } + weakest = append(weakest, topicAvg{name: topic, avg: subtotal / float64(len(scores))}) + } + if len(weakest) > 1 { + sort.Slice(weakest, func(i, j int) bool { + if weakest[i].avg == weakest[j].avg { + return weakest[i].name < weakest[j].name + } + return weakest[i].avg < weakest[j].avg + }) + } + weakestTopics := []string{} + for i, item := range weakest { + if i >= 2 { + break + } + weakestTopics = append(weakestTopics, displayTopic(item.name)) + } + weakestTopicText := "general fluency" + if len(weakestTopics) > 0 { + weakestTopicText = strings.Join(weakestTopics, ", ") + } + summary.NextStepOutcome = "accept" + if overall < 4.5 { + summary.NextStepOutcome = "redo" + } else if overall < 6.0 { + summary.NextStepOutcome = "support" + } + summary.AIFeedback = fmt.Sprintf( + "Student completed %s with %d/%d correct responses. Overall score is %.2f/10. The weakest areas were %s. %d question(s) need extra attention.", + summary.AssignmentName, + summary.CorrectCount, + len(summary.QuestionUpdates), + overall, + weakestTopicText, + summary.NeedsAttentionCnt, + ) +} + +func deriveUnderstandingScore(isCorrect bool, solveMode string) float64 { + if isCorrect { + return map[string]float64{ + "step_by_step": 0.95, + "handwritten": 0.85, + "just_answer": 0.75, + "solve_together": 0.65, + }[defaultSolveMode(solveMode)] + } + return map[string]float64{ + "step_by_step": 0.40, + "handwritten": 0.32, + "just_answer": 0.20, + "solve_together": 0.28, + }[defaultSolveMode(solveMode)] +} + +func deriveConfidenceScore(isCorrect bool, solveMode string) float64 { + if isCorrect { + return map[string]float64{ + "step_by_step": 0.82, + "handwritten": 0.78, + "just_answer": 0.90, + "solve_together": 0.62, + }[defaultSolveMode(solveMode)] + } + return map[string]float64{ + "step_by_step": 0.55, + "handwritten": 0.60, + "just_answer": 0.72, + "solve_together": 0.50, + }[defaultSolveMode(solveMode)] +} + +func deriveIssueReason(aiReasoning string, misconception *string) string { + if misconception != nil && strings.TrimSpace(*misconception) != "" { + switch strings.TrimSpace(*misconception) { + case "add_tops_add_bottoms": + return "The student added the numerator and denominator directly instead of finding a common denominator." + case "fraction_op_confusion": + return "The student confused the fraction operation and did not apply the correct method." + case "fraction_general_uncertainty": + return "The student shows insecure understanding of equivalent or comparable fractions." + case "place_value_misalignment": + return "The student misread place value, causing digits to be aligned incorrectly." + case "arithmetic_slip": + return "The final answer is wrong, suggesting a careless arithmetic slip rather than a secure method." + case "scaffolding_dependence": + return "The student appears dependent on scaffolding and does not show secure independent understanding." + case "word_problem_interpretation": + return "The student did not translate the word problem into the correct calculation." + default: + return strings.TrimSpace(*misconception) + } + } + text := strings.TrimSpace(aiReasoning) + if text == "" { + return "The answer shows incomplete understanding of the method." + } + return text +} + +func defaultSolveMode(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "just_answer" + } + return value +} + +func displayTopic(value string) string { + value = strings.ReplaceAll(strings.TrimSpace(value), "_", " ") + parts := strings.Fields(value) + for i, part := range parts { + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + return strings.Join(parts, " ") +} + +func boolToFloat(value bool) float64 { + if value { + return 1 + } + return 0 +} + +func roundToTwo(value float64) float64 { + return math.Round(value*100) / 100 +} + +func roundToThree(value float64) float64 { + return math.Round(value*1000) / 1000 +} + +func readJSON[T any](path string) (T, error) { + var zero T + data, err := os.ReadFile(path) + if err != nil { + return zero, err + } + var value T + if err := json.Unmarshal(data, &value); err != nil { + return zero, err + } + return value, nil +} + +func resolveMockDataDir() (string, error) { + if value := strings.TrimSpace(os.Getenv("MOCK_DATA_DIR")); value != "" { + return value, nil + } + candidates := []string{ + filepath.Join("..", "Mock-Data"), + filepath.Join(".", "Mock-Data"), + filepath.Join("..", "..", "Mock-Data"), + } + for _, candidate := range candidates { + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + return candidate, nil + } + } + return "", errors.New("Mock-Data directory not found; set MOCK_DATA_DIR") +} + +func valueOrElse(value *float64, fallback float64) float64 { + if value != nil { + return *value + } + return fallback +} + +func boolOrElse(value *bool, fallback bool) bool { + if value != nil { + return *value + } + return fallback +} + +func stringOrElse(value *string, fallback string) string { + if value != nil && strings.TrimSpace(*value) != "" { + return strings.TrimSpace(*value) + } + return strings.TrimSpace(fallback) +} + +func firstNonEmptyString(values ...*string) string { + for _, value := range values { + if value != nil && strings.TrimSpace(*value) != "" { + return strings.TrimSpace(*value) + } + } + return "" +} + +func sanitizeTags(tags []string, misconception *string) []string { + seen := map[string]bool{} + result := make([]string, 0, len(tags)+1) + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag == "" || seen[tag] { + continue + } + seen[tag] = true + result = append(result, tag) + } + if misconception != nil { + tag := strings.TrimSpace(*misconception) + if tag != "" && !seen[tag] { + result = append(result, tag) + } + } + return result +} + +func nullableString(value string) any { + if strings.TrimSpace(value) == "" { + return nil + } + return strings.TrimSpace(value) +} diff --git a/Backend/cmd/server/main.go b/Backend/cmd/server/main.go new file mode 100644 index 0000000..9ea573d --- /dev/null +++ b/Backend/cmd/server/main.go @@ -0,0 +1,81 @@ +// Path: Backend/cmd/server/main.go + +package main + +import ( + "boostai-backend/internal/config" + "boostai-backend/internal/database" + "boostai-backend/internal/router" + "context" + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/logger" + "github.com/gofiber/fiber/v2/middleware/recover" +) + +func main() { + cfg := config.Load() + + db, err := database.NewPostgres(cfg.DatabaseURL) + if err != nil { + log.Fatalf("failed to connect to database: %v", err) + } + defer db.Close() + log.Println("Connected to database") + + if err := db.Migrate(); err != nil { + log.Fatalf("failed to run migrations: %v", err) + } + log.Println("Database migrations complete") + + app := fiber.New(fiber.Config{ + AppName: "BoostAI Backend", + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 120 * time.Second, + }) + + app.Use(recover.New()) + app.Use(logger.New(logger.Config{ + Format: "${time} | ${status} | ${latency} | ${ip} | ${method} | ${path}\n", + TimeFormat: "2006-01-02 15:04:05", + })) + app.Use(cors.New(cors.Config{ + AllowOrigins: cfg.AllowedOrigins, + AllowMethods: "GET,POST,PUT,DELETE,PATCH,OPTIONS", + AllowHeaders: "Origin,Content-Type,Accept,Authorization", + AllowCredentials: true, + MaxAge: 86400, + })) + + router.Setup(app, cfg, db) + + go func() { + if err := app.Listen(":" + cfg.Port); err != nil { + log.Fatalf("failed to start server: %v", err) + } + }() + + log.Printf("BoostAI backend listening on port %s", cfg.Port) + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("Shutting down backend server...") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := app.ShutdownWithContext(ctx); err != nil { + log.Fatalf("server forced to shutdown: %v", err) + } + + log.Println("Backend server exited") +} diff --git a/Backend/db/embed.go b/Backend/db/embed.go new file mode 100644 index 0000000..e0a4e0b --- /dev/null +++ b/Backend/db/embed.go @@ -0,0 +1,6 @@ +package db + +import "embed" + +//go:embed migrations/*.sql +var Migrations embed.FS diff --git a/Backend/db/migrations/001_init.sql b/Backend/db/migrations/001_init.sql new file mode 100644 index 0000000..37c0c60 --- /dev/null +++ b/Backend/db/migrations/001_init.sql @@ -0,0 +1,160 @@ +-- +goose Up +CREATE TYPE user_role AS ENUM ('student', 'teacher'); +CREATE TYPE question_status AS ENUM ('draft', 'published', 'archived'); +CREATE TYPE assignment_status AS ENUM ('draft', 'assigned', 'closed'); +CREATE TYPE answer_status AS ENUM ('not_started', 'in_progress', 'submitted', 'reviewed'); + +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash TEXT, + role user_role NOT NULL, + full_name VARCHAR(255) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE classrooms ( + id BIGSERIAL PRIMARY KEY, + teacher_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + code VARCHAR(50) UNIQUE, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE classroom_students ( + classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE, + student_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (classroom_id, student_id) +); + +CREATE TABLE questions ( + id BIGSERIAL PRIMARY KEY, + author_teacher_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + title VARCHAR(255) NOT NULL, + prompt TEXT NOT NULL, + subject VARCHAR(100), + source TEXT, + status question_status NOT NULL DEFAULT 'draft', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE tags ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE question_tags ( + question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (question_id, tag_id) +); + +CREATE TABLE assignments ( + id BIGSERIAL PRIMARY KEY, + classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE, + teacher_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + title VARCHAR(255) NOT NULL, + instructions TEXT, + status assignment_status NOT NULL DEFAULT 'draft', + due_at TIMESTAMPTZ, + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE assignment_assignees ( + assignment_id BIGINT NOT NULL REFERENCES assignments(id) ON DELETE CASCADE, + student_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (assignment_id, student_id) +); + +CREATE TABLE assignment_questions ( + assignment_id BIGINT NOT NULL REFERENCES assignments(id) ON DELETE CASCADE, + question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE RESTRICT, + position INTEGER NOT NULL, + PRIMARY KEY (assignment_id, question_id), + CONSTRAINT assignment_questions_assignment_position_key UNIQUE (assignment_id, position) +); + +CREATE TABLE student_answers ( + id BIGSERIAL PRIMARY KEY, + assignment_id BIGINT NOT NULL REFERENCES assignments(id) ON DELETE CASCADE, + question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE RESTRICT, + student_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + answer_text TEXT, + ai_feedback TEXT, + teacher_feedback TEXT, + status answer_status NOT NULL DEFAULT 'not_started', + submitted_at TIMESTAMPTZ, + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT student_answers_assignment_question_student_key UNIQUE (assignment_id, question_id, student_id) +); + +CREATE INDEX idx_users_role ON users(role); +CREATE INDEX idx_classrooms_teacher_id ON classrooms(teacher_id); +CREATE INDEX idx_questions_author_teacher_id ON questions(author_teacher_id); +CREATE INDEX idx_questions_status ON questions(status); +CREATE INDEX idx_assignments_classroom_id ON assignments(classroom_id); +CREATE INDEX idx_assignments_teacher_id ON assignments(teacher_id); +CREATE INDEX idx_assignment_assignees_student_id ON assignment_assignees(student_id); +CREATE INDEX idx_student_answers_student_id ON student_answers(student_id); +CREATE INDEX idx_student_answers_assignment_id ON student_answers(assignment_id); + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +CREATE TRIGGER users_updated_at BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +CREATE TRIGGER classrooms_updated_at BEFORE UPDATE ON classrooms + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +CREATE TRIGGER questions_updated_at BEFORE UPDATE ON questions + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +CREATE TRIGGER assignments_updated_at BEFORE UPDATE ON assignments + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +CREATE TRIGGER student_answers_updated_at BEFORE UPDATE ON student_answers + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +-- +goose Down +DROP TRIGGER IF EXISTS student_answers_updated_at ON student_answers; +DROP TRIGGER IF EXISTS assignments_updated_at ON assignments; +DROP TRIGGER IF EXISTS questions_updated_at ON questions; +DROP TRIGGER IF EXISTS classrooms_updated_at ON classrooms; +DROP TRIGGER IF EXISTS users_updated_at ON users; +DROP FUNCTION IF EXISTS update_updated_at(); + +DROP TABLE IF EXISTS student_answers; +DROP TABLE IF EXISTS assignment_questions; +DROP TABLE IF EXISTS assignment_assignees; +DROP TABLE IF EXISTS assignments; +DROP TABLE IF EXISTS question_tags; +DROP TABLE IF EXISTS tags; +DROP TABLE IF EXISTS questions; +DROP TABLE IF EXISTS classroom_students; +DROP TABLE IF EXISTS classrooms; +DROP TABLE IF EXISTS users; + +DROP TYPE IF EXISTS answer_status; +DROP TYPE IF EXISTS assignment_status; +DROP TYPE IF EXISTS question_status; +DROP TYPE IF EXISTS user_role; diff --git a/Backend/db/migrations/002_profiles.sql b/Backend/db/migrations/002_profiles.sql new file mode 100644 index 0000000..e99403f --- /dev/null +++ b/Backend/db/migrations/002_profiles.sql @@ -0,0 +1,44 @@ +-- +goose Up +CREATE TABLE profiles ( + user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + preferred_name VARCHAR(100), + profile_icon_url TEXT, + headline VARCHAR(255), + bio TEXT, + timezone VARCHAR(100), + locale VARCHAR(20), + grade_level VARCHAR(100), + learning_goal TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO profiles (user_id) +SELECT id +FROM users +ON CONFLICT (user_id) DO NOTHING; + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION ensure_profile_for_user() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO profiles (user_id) + VALUES (NEW.id) + ON CONFLICT (user_id) DO NOTHING; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +CREATE TRIGGER profiles_updated_at BEFORE UPDATE ON profiles + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +CREATE TRIGGER users_ensure_profile_after_insert AFTER INSERT ON users + FOR EACH ROW EXECUTE FUNCTION ensure_profile_for_user(); + +-- +goose Down +DROP TRIGGER IF EXISTS users_ensure_profile_after_insert ON users; +DROP TRIGGER IF EXISTS profiles_updated_at ON profiles; +DROP FUNCTION IF EXISTS ensure_profile_for_user(); +DROP TABLE IF EXISTS profiles; diff --git a/Backend/db/migrations/003_messages.sql b/Backend/db/migrations/003_messages.sql new file mode 100644 index 0000000..123eadc --- /dev/null +++ b/Backend/db/migrations/003_messages.sql @@ -0,0 +1,48 @@ +-- +goose Up +CREATE TABLE message_threads ( + id BIGSERIAL PRIMARY KEY, + created_by_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + subject VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT message_threads_subject_not_blank CHECK (length(btrim(subject)) > 0) +); + +CREATE TABLE message_thread_participants ( + thread_id BIGINT NOT NULL REFERENCES message_threads(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_read_at TIMESTAMPTZ, + archived_at TIMESTAMPTZ, + PRIMARY KEY (thread_id, user_id) +); + +CREATE TABLE messages ( + id BIGSERIAL PRIMARY KEY, + thread_id BIGINT NOT NULL REFERENCES message_threads(id) ON DELETE CASCADE, + sender_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + body TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT messages_body_not_blank CHECK (length(btrim(body)) > 0) +); + +CREATE INDEX idx_message_threads_created_by_user_id ON message_threads(created_by_user_id); +CREATE INDEX idx_message_threads_updated_at ON message_threads(updated_at DESC); +CREATE INDEX idx_message_thread_participants_user_id ON message_thread_participants(user_id); +CREATE INDEX idx_messages_thread_id_created_at ON messages(thread_id, created_at DESC); +CREATE INDEX idx_messages_sender_user_id ON messages(sender_user_id); + +CREATE TRIGGER message_threads_updated_at BEFORE UPDATE ON message_threads + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +CREATE TRIGGER messages_updated_at BEFORE UPDATE ON messages + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +-- +goose Down +DROP TRIGGER IF EXISTS messages_updated_at ON messages; +DROP TRIGGER IF EXISTS message_threads_updated_at ON message_threads; + +DROP TABLE IF EXISTS messages; +DROP TABLE IF EXISTS message_thread_participants; +DROP TABLE IF EXISTS message_threads; diff --git a/Backend/db/migrations/004_student_answer_workspace.sql b/Backend/db/migrations/004_student_answer_workspace.sql new file mode 100644 index 0000000..82928b4 --- /dev/null +++ b/Backend/db/migrations/004_student_answer_workspace.sql @@ -0,0 +1,12 @@ +-- +goose Up +ALTER TABLE student_answers + ADD COLUMN solve_mode TEXT NOT NULL DEFAULT 'just_answer', + ADD COLUMN working_steps TEXT, + ADD CONSTRAINT student_answers_solve_mode_check + CHECK (solve_mode IN ('just_answer', 'step_by_step', 'solve_together', 'handwritten')); + +-- +goose Down +ALTER TABLE student_answers + DROP CONSTRAINT IF EXISTS student_answers_solve_mode_check, + DROP COLUMN IF EXISTS working_steps, + DROP COLUMN IF EXISTS solve_mode; diff --git a/Backend/db/migrations/005_question_answers_and_correctness.sql b/Backend/db/migrations/005_question_answers_and_correctness.sql new file mode 100644 index 0000000..d660f24 --- /dev/null +++ b/Backend/db/migrations/005_question_answers_and_correctness.sql @@ -0,0 +1,13 @@ +-- +goose Up +ALTER TABLE questions +ADD COLUMN correct_answer TEXT; + +ALTER TABLE student_answers +ADD COLUMN is_correct BOOLEAN; + +-- +goose Down +ALTER TABLE student_answers +DROP COLUMN IF EXISTS is_correct; + +ALTER TABLE questions +DROP COLUMN IF EXISTS correct_answer; diff --git a/Backend/db/migrations/006_assignment_level_feedback.sql b/Backend/db/migrations/006_assignment_level_feedback.sql new file mode 100644 index 0000000..c2534ce --- /dev/null +++ b/Backend/db/migrations/006_assignment_level_feedback.sql @@ -0,0 +1,53 @@ +-- +goose Up + +ALTER TABLE assignment_assignees + ADD COLUMN ai_feedback TEXT, + ADD COLUMN teacher_feedback TEXT; + +UPDATE assignment_assignees aa +SET ai_feedback = aggregated.ai_feedback +FROM ( + SELECT + sa.assignment_id, + sa.student_id, + string_agg( + format('Question %s: %s', aq.position, btrim(sa.ai_feedback)), + E'\n\n' + ORDER BY aq.position ASC + ) AS ai_feedback + FROM student_answers sa + JOIN assignment_questions aq + ON aq.assignment_id = sa.assignment_id + AND aq.question_id = sa.question_id + WHERE NULLIF(btrim(sa.ai_feedback), '') IS NOT NULL + GROUP BY sa.assignment_id, sa.student_id +) AS aggregated +WHERE aa.assignment_id = aggregated.assignment_id + AND aa.student_id = aggregated.student_id; + +UPDATE assignment_assignees aa +SET teacher_feedback = aggregated.teacher_feedback +FROM ( + SELECT + sa.assignment_id, + sa.student_id, + string_agg( + format('Question %s: %s', aq.position, btrim(sa.teacher_feedback)), + E'\n\n' + ORDER BY aq.position ASC + ) AS teacher_feedback + FROM student_answers sa + JOIN assignment_questions aq + ON aq.assignment_id = sa.assignment_id + AND aq.question_id = sa.question_id + WHERE NULLIF(btrim(sa.teacher_feedback), '') IS NOT NULL + GROUP BY sa.assignment_id, sa.student_id +) AS aggregated +WHERE aa.assignment_id = aggregated.assignment_id + AND aa.student_id = aggregated.student_id; + +-- +goose Down + +ALTER TABLE assignment_assignees + DROP COLUMN IF EXISTS teacher_feedback, + DROP COLUMN IF EXISTS ai_feedback; diff --git a/Backend/db/migrations/007_review_contract.sql b/Backend/db/migrations/007_review_contract.sql new file mode 100644 index 0000000..341e0e5 --- /dev/null +++ b/Backend/db/migrations/007_review_contract.sql @@ -0,0 +1,66 @@ +-- +goose Up + +CREATE TYPE assignment_pass_status AS ENUM ('pending', 'pass', 'no_pass'); + +ALTER TABLE student_answers + ADD COLUMN review_needs_attention BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN review_issue_reason TEXT, + ADD COLUMN review_correctness_score NUMERIC(4,3), + ADD COLUMN review_understanding_score NUMERIC(4,3), + ADD COLUMN review_question_score NUMERIC(4,3), + ADD COLUMN review_confidence NUMERIC(4,3), + ADD COLUMN review_tags TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + ADD CONSTRAINT student_answers_review_correctness_score_range_check + CHECK (review_correctness_score IS NULL OR (review_correctness_score >= 0 AND review_correctness_score <= 1)), + ADD CONSTRAINT student_answers_review_understanding_score_range_check + CHECK (review_understanding_score IS NULL OR (review_understanding_score >= 0 AND review_understanding_score <= 1)), + ADD CONSTRAINT student_answers_review_question_score_range_check + CHECK (review_question_score IS NULL OR (review_question_score >= 0 AND review_question_score <= 1)), + ADD CONSTRAINT student_answers_review_confidence_range_check + CHECK (review_confidence IS NULL OR (review_confidence >= 0 AND review_confidence <= 1)); + +UPDATE student_answers +SET review_correctness_score = CASE + WHEN is_correct IS TRUE THEN 1.000 + WHEN is_correct IS FALSE THEN 0.000 + ELSE NULL + END, + review_question_score = CASE + WHEN is_correct IS TRUE THEN 1.000 + WHEN is_correct IS FALSE THEN 0.000 + ELSE NULL + END +WHERE is_correct IS NOT NULL; + +ALTER TABLE assignment_assignees + ADD COLUMN overall_score NUMERIC(5,2), + ADD COLUMN pass_threshold NUMERIC(5,2) NOT NULL DEFAULT 8.00, + ADD COLUMN pass_status assignment_pass_status NOT NULL DEFAULT 'pending', + ADD CONSTRAINT assignment_assignees_overall_score_range_check + CHECK (overall_score IS NULL OR (overall_score >= 0 AND overall_score <= 10)), + ADD CONSTRAINT assignment_assignees_pass_threshold_range_check + CHECK (pass_threshold >= 0 AND pass_threshold <= 10); + +-- +goose Down + +ALTER TABLE assignment_assignees + DROP CONSTRAINT IF EXISTS assignment_assignees_pass_threshold_range_check, + DROP CONSTRAINT IF EXISTS assignment_assignees_overall_score_range_check, + DROP COLUMN IF EXISTS pass_status, + DROP COLUMN IF EXISTS pass_threshold, + DROP COLUMN IF EXISTS overall_score; + +ALTER TABLE student_answers + DROP CONSTRAINT IF EXISTS student_answers_review_confidence_range_check, + DROP CONSTRAINT IF EXISTS student_answers_review_question_score_range_check, + DROP CONSTRAINT IF EXISTS student_answers_review_understanding_score_range_check, + DROP CONSTRAINT IF EXISTS student_answers_review_correctness_score_range_check, + DROP COLUMN IF EXISTS review_tags, + DROP COLUMN IF EXISTS review_confidence, + DROP COLUMN IF EXISTS review_question_score, + DROP COLUMN IF EXISTS review_understanding_score, + DROP COLUMN IF EXISTS review_correctness_score, + DROP COLUMN IF EXISTS review_issue_reason, + DROP COLUMN IF EXISTS review_needs_attention; + +DROP TYPE IF EXISTS assignment_pass_status; diff --git a/Backend/db/migrations/008_assignment_pass_threshold.sql b/Backend/db/migrations/008_assignment_pass_threshold.sql new file mode 100644 index 0000000..143ec56 --- /dev/null +++ b/Backend/db/migrations/008_assignment_pass_threshold.sql @@ -0,0 +1,22 @@ +-- +goose Up + +ALTER TABLE assignments + ADD COLUMN pass_threshold NUMERIC(5,2) NOT NULL DEFAULT 8.00, + ADD CONSTRAINT assignments_pass_threshold_range_check + CHECK (pass_threshold >= 0 AND pass_threshold <= 10); + +UPDATE assignments a +SET pass_threshold = COALESCE( + ( + SELECT MAX(aa.pass_threshold) + FROM assignment_assignees aa + WHERE aa.assignment_id = a.id + ), + 8.00 +); + +-- +goose Down + +ALTER TABLE assignments + DROP CONSTRAINT IF EXISTS assignments_pass_threshold_range_check, + DROP COLUMN IF EXISTS pass_threshold; diff --git a/Backend/db/migrations/009_assignment_pass_status_override.sql b/Backend/db/migrations/009_assignment_pass_status_override.sql new file mode 100644 index 0000000..8045bd7 --- /dev/null +++ b/Backend/db/migrations/009_assignment_pass_status_override.sql @@ -0,0 +1,9 @@ +-- +goose Up + +ALTER TABLE assignment_assignees + ADD COLUMN pass_status_override assignment_pass_status; + +-- +goose Down + +ALTER TABLE assignment_assignees + DROP COLUMN IF EXISTS pass_status_override; diff --git a/Backend/db/migrations/010_assignment_next_step_outcome.sql b/Backend/db/migrations/010_assignment_next_step_outcome.sql new file mode 100644 index 0000000..863877a --- /dev/null +++ b/Backend/db/migrations/010_assignment_next_step_outcome.sql @@ -0,0 +1,13 @@ +-- +goose Up + +CREATE TYPE assignment_next_step_outcome AS ENUM ('redo', 'accept', 'support'); + +ALTER TABLE assignment_assignees + ADD COLUMN next_step_outcome assignment_next_step_outcome; + +-- +goose Down + +ALTER TABLE assignment_assignees + DROP COLUMN IF EXISTS next_step_outcome; + +DROP TYPE IF EXISTS assignment_next_step_outcome; diff --git a/Backend/db/migrations/011_backfill_seeded_question_correct_answers.sql b/Backend/db/migrations/011_backfill_seeded_question_correct_answers.sql new file mode 100644 index 0000000..c69238d --- /dev/null +++ b/Backend/db/migrations/011_backfill_seeded_question_correct_answers.sql @@ -0,0 +1,80 @@ +-- +goose Up + +UPDATE questions AS q +SET correct_answer = seeded.correct_answer +FROM ( + VALUES + (1001, '700'), + (1002, '25000'), + (1003, '7/100'), + (1004, '0.37'), + (1101, '383'), + (1102, '456'), + (1103, '2627'), + (1104, '196'), + (1105, '24'), + (1106, '3744'), + (1201, '3'), + (1202, '-5'), + (1203, '-4'), + (1301, '11'), + (1302, '22'), + (1303, '19'), + (1401, '1/2'), + (1402, '2/3'), + (1403, '5/8'), + (1411, '5/6'), + (1412, '3/4'), + (1413, '11/15'), + (1414, '11/12'), + (1415, '1/2'), + (1416, '29/24'), + (1421, '1/6'), + (1422, '1/2'), + (1423, '28'), + (1501, '5'), + (1502, '7'), + (1503, '6'), + (1511, '14'), + (1512, '31'), + (1513, '3n+2'), + (1601, '28'), + (1602, '40'), + (1603, '27'), + (1611, '70'), + (1612, '75'), + (1613, '720'), + (1701, '8'), + (1702, '5'), + (1703, '7'), + (1711, '3/8'), + (1712, '1/3'), + (1801, '15'), + (1802, '3/8'), + (1803, '51'), + (1804, '23'), + (1805, '96') +) AS seeded(id, correct_answer) +WHERE q.id = seeded.id + AND (q.correct_answer IS NULL OR BTRIM(q.correct_answer) = ''); + +-- +goose Down + +UPDATE questions +SET correct_answer = NULL +WHERE id IN ( + 1001, 1002, 1003, 1004, + 1101, 1102, 1103, 1104, 1105, 1106, + 1201, 1202, 1203, + 1301, 1302, 1303, + 1401, 1402, 1403, + 1411, 1412, 1413, 1414, 1415, 1416, + 1421, 1422, 1423, + 1501, 1502, 1503, + 1511, 1512, 1513, + 1601, 1602, 1603, + 1611, 1612, 1613, + 1701, 1702, 1703, + 1711, 1712, + 1801, 1802, 1803, 1804, 1805 +); diff --git a/Backend/db/migrations/012_phase_a_topics_difficulty_and_threshold.sql b/Backend/db/migrations/012_phase_a_topics_difficulty_and_threshold.sql new file mode 100644 index 0000000..59533bc --- /dev/null +++ b/Backend/db/migrations/012_phase_a_topics_difficulty_and_threshold.sql @@ -0,0 +1,123 @@ +-- +goose Up + +CREATE TYPE question_topic AS ENUM ( + 'place_value', + 'arithmetic', + 'negative_numbers', + 'bidmas', + 'fractions', + 'algebra', + 'geometry', + 'data' +); + +CREATE TYPE question_difficulty AS ENUM ('easy', 'medium', 'hard'); + +ALTER TABLE questions + ADD COLUMN topic question_topic, + ADD COLUMN difficulty question_difficulty; + +UPDATE questions +SET topic = CASE BTRIM(COALESCE(subject, '')) + WHEN 'Place Value' THEN 'place_value'::question_topic + WHEN 'Arithmetic' THEN 'arithmetic'::question_topic + WHEN 'Negative Numbers' THEN 'negative_numbers'::question_topic + WHEN 'BIDMAS' THEN 'bidmas'::question_topic + WHEN 'Fractions' THEN 'fractions'::question_topic + WHEN 'Algebra' THEN 'algebra'::question_topic + WHEN 'Geometry' THEN 'geometry'::question_topic + WHEN 'Data' THEN 'data'::question_topic + ELSE NULL +END +WHERE topic IS NULL; + +UPDATE questions AS q +SET difficulty = seeded.difficulty::question_difficulty +FROM ( + VALUES + (1001, 'easy'), + (1002, 'medium'), + (1003, 'medium'), + (1004, 'hard'), + (1101, 'easy'), + (1102, 'medium'), + (1103, 'hard'), + (1104, 'medium'), + (1105, 'medium'), + (1106, 'hard'), + (1201, 'easy'), + (1202, 'medium'), + (1203, 'hard'), + (1301, 'easy'), + (1302, 'medium'), + (1303, 'hard'), + (1401, 'easy'), + (1402, 'medium'), + (1403, 'hard'), + (1411, 'easy'), + (1412, 'easy'), + (1413, 'medium'), + (1414, 'medium'), + (1415, 'medium'), + (1416, 'hard'), + (1421, 'easy'), + (1422, 'medium'), + (1423, 'hard'), + (1501, 'easy'), + (1502, 'medium'), + (1503, 'hard'), + (1511, 'easy'), + (1512, 'medium'), + (1513, 'hard'), + (1601, 'easy'), + (1602, 'medium'), + (1603, 'hard'), + (1611, 'easy'), + (1612, 'medium'), + (1613, 'hard'), + (1701, 'easy'), + (1702, 'medium'), + (1703, 'easy'), + (1711, 'medium'), + (1712, 'hard'), + (1801, 'easy'), + (1802, 'medium'), + (1803, 'medium'), + (1804, 'hard'), + (1805, 'hard') +) AS seeded(id, difficulty) +WHERE q.id = seeded.id + AND q.difficulty IS NULL; + +ALTER TABLE assignments + ALTER COLUMN pass_threshold SET DEFAULT 6.00; + +UPDATE assignments +SET pass_threshold = 6.00; + +ALTER TABLE assignment_assignees + ALTER COLUMN pass_threshold SET DEFAULT 6.00; + +UPDATE assignment_assignees +SET pass_threshold = 6.00; + +-- +goose Down + +UPDATE assignment_assignees +SET pass_threshold = 8.00; + +ALTER TABLE assignment_assignees + ALTER COLUMN pass_threshold SET DEFAULT 8.00; + +UPDATE assignments +SET pass_threshold = 8.00; + +ALTER TABLE assignments + ALTER COLUMN pass_threshold SET DEFAULT 8.00; + +ALTER TABLE questions + DROP COLUMN IF EXISTS difficulty, + DROP COLUMN IF EXISTS topic; + +DROP TYPE IF EXISTS question_difficulty; +DROP TYPE IF EXISTS question_topic; diff --git a/Backend/db/migrations/013_redo_assignment_plan.sql b/Backend/db/migrations/013_redo_assignment_plan.sql new file mode 100644 index 0000000..a8c14dd --- /dev/null +++ b/Backend/db/migrations/013_redo_assignment_plan.sql @@ -0,0 +1,11 @@ +-- +goose Up + +ALTER TABLE assignment_assignees + ADD COLUMN redo_plan TEXT, + ADD COLUMN redo_plan_generated_at TIMESTAMPTZ; + +-- +goose Down + +ALTER TABLE assignment_assignees + DROP COLUMN IF EXISTS redo_plan_generated_at, + DROP COLUMN IF EXISTS redo_plan; diff --git a/Backend/db/migrations/014_assignment_student_questions.sql b/Backend/db/migrations/014_assignment_student_questions.sql new file mode 100644 index 0000000..b6c9d05 --- /dev/null +++ b/Backend/db/migrations/014_assignment_student_questions.sql @@ -0,0 +1,30 @@ +-- +goose Up + +CREATE TABLE assignment_student_questions ( + id BIGSERIAL PRIMARY KEY, + assignment_id BIGINT NOT NULL, + student_id BIGINT NOT NULL, + question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + position INTEGER NOT NULL CHECK (position > 0), + source_bucket TEXT NOT NULL CHECK (btrim(source_bucket) <> ''), + source_topic question_topic, + source_difficulty question_difficulty, + generator_seed BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT assignment_student_questions_assignment_student_fkey + FOREIGN KEY (assignment_id, student_id) + REFERENCES assignment_assignees(assignment_id, student_id) + ON DELETE CASCADE, + CONSTRAINT assignment_student_questions_assignment_student_question_key + UNIQUE (assignment_id, student_id, question_id), + CONSTRAINT assignment_student_questions_assignment_student_position_key + UNIQUE (assignment_id, student_id, position) +); + +CREATE INDEX idx_assignment_student_questions_assignment_student + ON assignment_student_questions (assignment_id, student_id); + +-- +goose Down + +DROP INDEX IF EXISTS idx_assignment_student_questions_assignment_student; +DROP TABLE IF EXISTS assignment_student_questions; diff --git a/Backend/db/queries/assignments.sql b/Backend/db/queries/assignments.sql new file mode 100644 index 0000000..6211190 --- /dev/null +++ b/Backend/db/queries/assignments.sql @@ -0,0 +1,391 @@ +-- name: CreateAssignment :one +INSERT INTO assignments ( + classroom_id, + teacher_id, + title, + instructions, + status, + due_at, + published_at, + pass_threshold +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8 +) +RETURNING *; + +-- name: AssignStudentToAssignment :exec +INSERT INTO assignment_assignees ( + assignment_id, + student_id +) VALUES ( + $1, + $2 +) +ON CONFLICT (assignment_id, student_id) DO NOTHING; + +-- name: DeleteAssignmentAssignee :exec +DELETE FROM assignment_assignees +WHERE assignment_id = $1 + AND student_id = $2; + +-- name: GetAssignmentAssignee :one +SELECT * +FROM assignment_assignees +WHERE assignment_id = $1 + AND student_id = $2; + +-- name: UpdateAssignmentAIReview :one +UPDATE assignment_assignees +SET ai_feedback = $3, + next_step_outcome = NULLIF($4::text, '')::assignment_next_step_outcome +WHERE assignment_id = $1 + AND student_id = $2 +RETURNING *; + +-- name: UpdateAssignmentRedoPlan :one +UPDATE assignment_assignees +SET redo_plan = NULLIF($3::text, ''), + redo_plan_generated_at = CASE + WHEN NULLIF($3::text, '') IS NULL THEN NULL + ELSE NOW() + END +WHERE assignment_id = $1 + AND student_id = $2 +RETURNING *; + +-- name: GetAssignmentRedoPlan :one +SELECT + assignment_id, + student_id, + redo_plan, + redo_plan_generated_at +FROM assignment_assignees +WHERE assignment_id = $1 + AND student_id = $2; + +-- name: UpdateAssignmentTeacherFeedback :one +WITH student_question_set AS ( + SELECT asq.assignment_id, asq.question_id, asq.position + FROM assignment_student_questions asq + WHERE asq.assignment_id = $1 + AND asq.student_id = $2 +), selected_questions AS ( + SELECT assignment_id, question_id, position + FROM student_question_set + UNION ALL + SELECT aq.assignment_id, aq.question_id, aq.position + FROM assignment_questions aq + WHERE aq.assignment_id = $1 + AND NOT EXISTS (SELECT 1 FROM student_question_set) +), score_summary AS ( + SELECT CASE + WHEN COUNT(sa.id) = 0 THEN NULL + ELSE ROUND((AVG( + CASE + WHEN sa.is_correct IS NULL THEN COALESCE(sa.review_understanding_score, 0)::NUMERIC + ELSE ( + ((CASE WHEN sa.is_correct THEN 1 ELSE 0 END)::NUMERIC) + COALESCE(sa.review_understanding_score, 0)::NUMERIC + ) / 2 + END + ) * 10)::NUMERIC, 2) + END AS overall_score + FROM selected_questions aq + LEFT JOIN student_answers sa + ON sa.assignment_id = aq.assignment_id + AND sa.question_id = aq.question_id + AND sa.student_id = $2 + WHERE aq.assignment_id = $1 +), updated AS ( + UPDATE assignment_assignees aa + SET teacher_feedback = $3, + pass_status_override = NULLIF($4::text, '')::assignment_pass_status, + next_step_outcome = NULLIF($5::text, '')::assignment_next_step_outcome, + overall_score = (SELECT overall_score FROM score_summary), + pass_status = COALESCE( + NULLIF($4::text, '')::assignment_pass_status, + CASE + WHEN (SELECT overall_score FROM score_summary) IS NULL THEN 'pending'::assignment_pass_status + WHEN (SELECT overall_score FROM score_summary) >= a.pass_threshold THEN 'pass'::assignment_pass_status + ELSE 'no_pass'::assignment_pass_status + END + ) + FROM assignments a + WHERE aa.assignment_id = $1 + AND aa.student_id = $2 + AND a.id = aa.assignment_id + RETURNING aa.* +) +SELECT * +FROM updated; + +-- name: AddQuestionToAssignment :exec +INSERT INTO assignment_questions ( + assignment_id, + question_id, + position +) VALUES ( + $1, + $2, + $3 +) +ON CONFLICT (assignment_id, question_id) DO UPDATE +SET position = EXCLUDED.position; + +-- name: DeleteAssignmentStudentQuestions :exec +DELETE FROM assignment_student_questions +WHERE assignment_id = $1 + AND student_id = $2; + +-- name: AddAssignmentStudentQuestion :one +INSERT INTO assignment_student_questions ( + assignment_id, + student_id, + question_id, + position, + source_bucket, + source_topic, + source_difficulty, + generator_seed +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8 +) +RETURNING *; + +-- name: ListAssignmentStudentQuestions :many +SELECT * +FROM assignment_student_questions +WHERE assignment_id = $1 + AND student_id = $2 +ORDER BY position ASC, id ASC; + +-- name: ListGeneratedQuestionsForAssignmentStudent :many +SELECT + asq.id, + asq.assignment_id, + asq.student_id, + asq.question_id, + asq.position, + asq.source_bucket, + asq.source_topic, + asq.source_difficulty, + asq.generator_seed, + asq.created_at, + q.author_teacher_id, + q.title, + q.prompt, + q.subject, + q.source, + q.status, + q.created_at AS question_created_at, + q.updated_at AS question_updated_at, + q.correct_answer, + q.topic, + q.difficulty +FROM assignment_student_questions asq +JOIN questions q ON q.id = asq.question_id +WHERE asq.assignment_id = $1 + AND asq.student_id = $2 +ORDER BY asq.position ASC, asq.id ASC; + +-- name: ListAssignmentsByTeacher :many +SELECT * +FROM assignments +WHERE teacher_id = $1 +ORDER BY created_at DESC; + +-- name: ListAssignmentsForStudent :many +SELECT a.* +FROM assignment_assignees aa +JOIN assignments a ON a.id = aa.assignment_id +WHERE aa.student_id = $1 +ORDER BY a.created_at DESC; + +-- name: GetAssignmentByID :one +SELECT * +FROM assignments +WHERE id = $1; + +-- name: UpdateAssignmentDraft :one +UPDATE assignments +SET classroom_id = $2, + title = $3, + instructions = $4, + due_at = $5, + pass_threshold = $6, + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: CloseAssignment :one +UPDATE assignments +SET status = 'closed'::assignment_status, + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: ListQuestionsForAssignment :many +SELECT + aq.assignment_id, + aq.question_id, + aq.position, + q.author_teacher_id, + q.title, + q.prompt, + q.subject, + q.source, + q.status, + q.created_at, + q.updated_at +FROM assignment_questions aq +JOIN questions q ON q.id = aq.question_id +WHERE aq.assignment_id = $1 +ORDER BY aq.position ASC, aq.question_id ASC; + +-- name: GetAssignmentReviewSummary :one +WITH student_question_set AS ( + SELECT asq.student_id, asq.question_id, asq.position + FROM assignment_student_questions asq + WHERE asq.assignment_id = $1 +), +students_with_personalized AS ( + SELECT DISTINCT student_id + FROM student_question_set +), +selected_questions AS ( + SELECT student_id, question_id, position + FROM student_question_set + UNION ALL + SELECT aa.student_id, aq.question_id, aq.position + FROM assignment_assignees aa + JOIN assignment_questions aq ON aq.assignment_id = aa.assignment_id + WHERE aa.assignment_id = $1 + AND NOT EXISTS ( + SELECT 1 + FROM students_with_personalized swp + WHERE swp.student_id = aa.student_id + ) +), +student_states AS ( + SELECT + aa.student_id, + COUNT(sq.question_id)::BIGINT AS total_questions, + CASE + WHEN COUNT(sa.id) = 0 THEN 'not_started'::answer_status + WHEN COUNT(sq.question_id) > 0 + AND COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') = COUNT(sq.question_id) + THEN 'reviewed'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'submitted') > 0 THEN 'submitted'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'in_progress') > 0 THEN 'in_progress'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') > 0 THEN 'in_progress'::answer_status + ELSE 'not_started'::answer_status + END AS review_status + FROM assignment_assignees aa + LEFT JOIN selected_questions sq + ON sq.student_id = aa.student_id + LEFT JOIN student_answers sa + ON sa.assignment_id = aa.assignment_id + AND sa.question_id = sq.question_id + AND sa.student_id = aa.student_id + WHERE aa.assignment_id = $1 + GROUP BY aa.student_id +) +SELECT + $1::BIGINT AS assignment_id, + COALESCE(MAX(student_states.total_questions), 0)::BIGINT AS total_questions, + COUNT(*)::BIGINT AS total_assigned, + COUNT(*) FILTER (WHERE review_status = 'not_started')::BIGINT AS not_started, + COUNT(*) FILTER (WHERE review_status = 'in_progress')::BIGINT AS in_progress, + COUNT(*) FILTER (WHERE review_status = 'submitted')::BIGINT AS submitted, + COUNT(*) FILTER (WHERE review_status = 'reviewed')::BIGINT AS reviewed +FROM student_states; + +-- name: ListAssignmentReviewQueue :many +WITH student_question_set AS ( + SELECT asq.student_id, asq.question_id, asq.position + FROM assignment_student_questions asq + WHERE asq.assignment_id = $1 +), +students_with_personalized AS ( + SELECT DISTINCT student_id + FROM student_question_set +), +selected_questions AS ( + SELECT student_id, question_id, position + FROM student_question_set + UNION ALL + SELECT aa.student_id, aq.question_id, aq.position + FROM assignment_assignees aa + JOIN assignment_questions aq ON aq.assignment_id = aa.assignment_id + WHERE aa.assignment_id = $1 + AND NOT EXISTS ( + SELECT 1 + FROM students_with_personalized swp + WHERE swp.student_id = aa.student_id + ) +), +student_states AS ( + SELECT + aa.assignment_id, + aa.student_id, + aa.next_step_outcome, + u.full_name AS student_name, + u.email AS student_email, + COUNT(sq.question_id)::BIGINT AS total_questions, + COUNT(sa.id)::BIGINT AS answered_questions, + COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed')::BIGINT AS reviewed_questions, + COUNT(sa.id) FILTER (WHERE sa.status = 'submitted')::BIGINT AS submitted_questions, + COUNT(sa.id) FILTER (WHERE sa.status = 'in_progress')::BIGINT AS in_progress_questions, + MAX(sa.submitted_at)::timestamptz AS latest_submitted_at, + MAX(sa.reviewed_at)::timestamptz AS latest_reviewed_at, + CASE + WHEN COUNT(sa.id) = 0 THEN 'not_started'::answer_status + WHEN COUNT(sq.question_id) > 0 + AND COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') = COUNT(sq.question_id) + THEN 'reviewed'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'submitted') > 0 THEN 'submitted'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'in_progress') > 0 THEN 'in_progress'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') > 0 THEN 'in_progress'::answer_status + ELSE 'not_started'::answer_status + END AS review_status + FROM assignment_assignees aa + JOIN users u ON u.id = aa.student_id + LEFT JOIN selected_questions sq + ON sq.student_id = aa.student_id + LEFT JOIN student_answers sa + ON sa.assignment_id = aa.assignment_id + AND sa.question_id = sq.question_id + AND sa.student_id = aa.student_id + WHERE aa.assignment_id = $1 + GROUP BY aa.assignment_id, aa.student_id, aa.next_step_outcome, u.full_name, u.email +) +SELECT + student_states.assignment_id, + student_states.student_id, + student_states.next_step_outcome, + student_states.student_name, + student_states.student_email, + student_states.total_questions, + student_states.answered_questions, + student_states.reviewed_questions, + student_states.submitted_questions, + student_states.in_progress_questions, + student_states.review_status, + student_states.latest_submitted_at, + student_states.latest_reviewed_at +FROM student_states +WHERE ($2::text = '' OR review_status::text = $2::text) +ORDER BY student_states.student_name ASC, student_states.student_id ASC; diff --git a/Backend/db/queries/classrooms.sql b/Backend/db/queries/classrooms.sql new file mode 100644 index 0000000..747498a --- /dev/null +++ b/Backend/db/queries/classrooms.sql @@ -0,0 +1,36 @@ +-- name: CreateClassroom :one +INSERT INTO classrooms ( + teacher_id, + name, + code, + description +) VALUES ( + $1, + $2, + $3, + $4 +) +RETURNING *; + +-- name: ListClassroomsByTeacher :many +SELECT * +FROM classrooms +WHERE teacher_id = $1 +ORDER BY created_at DESC; + +-- name: AddStudentToClassroom :exec +INSERT INTO classroom_students ( + classroom_id, + student_id +) VALUES ( + $1, + $2 +) +ON CONFLICT (classroom_id, student_id) DO NOTHING; + +-- name: ListStudentsForClassroom :many +SELECT u.* +FROM classroom_students cs +JOIN users u ON u.id = cs.student_id +WHERE cs.classroom_id = $1 +ORDER BY u.full_name ASC; diff --git a/Backend/db/queries/messages.sql b/Backend/db/queries/messages.sql new file mode 100644 index 0000000..6cef17c --- /dev/null +++ b/Backend/db/queries/messages.sql @@ -0,0 +1,266 @@ +-- name: ListMessageRecipientsForUser :many +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + p.preferred_name, + p.profile_icon_url, + p.headline +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.id <> $1 + AND u.is_active = TRUE + AND ( + EXISTS ( + SELECT 1 + FROM classrooms c + JOIN classroom_students cs ON cs.classroom_id = c.id + WHERE c.teacher_id = u.id + AND cs.student_id = $1 + ) + OR EXISTS ( + SELECT 1 + FROM classrooms c + JOIN classroom_students cs ON cs.classroom_id = c.id + WHERE c.teacher_id = $1 + AND cs.student_id = u.id + ) + ) +ORDER BY COALESCE(NULLIF(p.preferred_name, ''), u.full_name) ASC, u.id ASC; + +-- name: GetMessageRecipientByIDForUser :one +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + p.preferred_name, + p.profile_icon_url, + p.headline +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.id = $2 + AND u.id <> $1 + AND u.is_active = TRUE + AND ( + EXISTS ( + SELECT 1 + FROM classrooms c + JOIN classroom_students cs ON cs.classroom_id = c.id + WHERE c.teacher_id = u.id + AND cs.student_id = $1 + ) + OR EXISTS ( + SELECT 1 + FROM classrooms c + JOIN classroom_students cs ON cs.classroom_id = c.id + WHERE c.teacher_id = $1 + AND cs.student_id = u.id + ) + ) +LIMIT 1; + +-- name: ListMessageThreadsForUser :many +SELECT + t.id AS thread_id, + t.subject, + t.created_by_user_id, + t.created_at AS thread_created_at, + t.updated_at AS thread_updated_at, + COALESCE(last_message.id, 0)::bigint AS last_message_id, + COALESCE(last_message.body, '') AS last_message_body, + last_message.created_at AS last_message_created_at, + COALESCE(last_message.sender_user_id, 0)::bigint AS last_message_sender_user_id, + sender.full_name AS last_message_sender_full_name, + sender_profile.preferred_name AS last_message_sender_preferred_name, + sender_profile.profile_icon_url AS last_message_sender_profile_icon_url, + COALESCE(( + SELECT COUNT(*)::bigint + FROM messages unread + WHERE unread.thread_id = t.id + AND unread.sender_user_id <> $1 + AND (participant.last_read_at IS NULL OR unread.created_at > participant.last_read_at) + ), 0)::bigint AS unread_count +FROM message_thread_participants participant +JOIN message_threads t ON t.id = participant.thread_id +LEFT JOIN LATERAL ( + SELECT m.id, m.body, m.created_at, m.sender_user_id + FROM messages m + WHERE m.thread_id = t.id + ORDER BY m.created_at DESC, m.id DESC + LIMIT 1 +) AS last_message ON TRUE +LEFT JOIN users sender ON sender.id = last_message.sender_user_id +LEFT JOIN profiles sender_profile ON sender_profile.user_id = sender.id +WHERE participant.user_id = $1 + AND participant.archived_at IS NULL +ORDER BY COALESCE(last_message.created_at, t.updated_at) DESC, t.id DESC; + +-- name: ListMessageThreadParticipantsForUser :many +SELECT + mtp.thread_id, + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + p.preferred_name, + p.profile_icon_url, + p.headline, + mtp.joined_at, + mtp.last_read_at, + mtp.archived_at +FROM message_thread_participants mtp +JOIN users u ON u.id = mtp.user_id +LEFT JOIN profiles p ON p.user_id = u.id +WHERE mtp.thread_id IN ( + SELECT participant.thread_id + FROM message_thread_participants participant + WHERE participant.user_id = $1 + AND participant.archived_at IS NULL +) +ORDER BY mtp.thread_id ASC, COALESCE(NULLIF(p.preferred_name, ''), u.full_name) ASC, u.id ASC; + +-- name: GetMessageThreadForUser :one +SELECT + t.id, + t.subject, + t.created_by_user_id, + t.created_at, + t.updated_at, + participant.last_read_at, + COALESCE(( + SELECT COUNT(*)::bigint + FROM messages unread + WHERE unread.thread_id = t.id + AND unread.sender_user_id <> $2 + AND (participant.last_read_at IS NULL OR unread.created_at > participant.last_read_at) + ), 0)::bigint AS unread_count +FROM message_threads t +JOIN message_thread_participants participant ON participant.thread_id = t.id +WHERE t.id = $1 + AND participant.user_id = $2 + AND participant.archived_at IS NULL; + +-- name: ListMessagesForThreadForUser :many +SELECT + m.id, + m.thread_id, + m.sender_user_id, + m.body, + m.created_at, + m.updated_at, + sender.email AS sender_email, + sender.role AS sender_role, + sender.full_name AS sender_full_name, + sender_profile.preferred_name AS sender_preferred_name, + sender_profile.profile_icon_url AS sender_profile_icon_url, + sender_profile.headline AS sender_headline +FROM messages m +JOIN message_thread_participants participant ON participant.thread_id = m.thread_id +JOIN users sender ON sender.id = m.sender_user_id +LEFT JOIN profiles sender_profile ON sender_profile.user_id = sender.id +WHERE m.thread_id = $1 + AND participant.user_id = $2 + AND participant.archived_at IS NULL +ORDER BY m.created_at ASC, m.id ASC; + +-- name: ListParticipantsForThreadForUser :many +SELECT + mtp.thread_id, + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + p.preferred_name, + p.profile_icon_url, + p.headline, + mtp.joined_at, + mtp.last_read_at, + mtp.archived_at +FROM message_thread_participants mtp +JOIN users u ON u.id = mtp.user_id +LEFT JOIN profiles p ON p.user_id = u.id +WHERE mtp.thread_id = $1 + AND EXISTS ( + SELECT 1 + FROM message_thread_participants participant + WHERE participant.thread_id = mtp.thread_id + AND participant.user_id = $2 + AND participant.archived_at IS NULL + ) +ORDER BY COALESCE(NULLIF(p.preferred_name, ''), u.full_name) ASC, u.id ASC; + +-- name: CreateMessageThread :one +INSERT INTO message_threads ( + created_by_user_id, + subject +) VALUES ( + $1, + $2 +) +RETURNING *; + +-- name: AddMessageThreadParticipant :exec +INSERT INTO message_thread_participants ( + thread_id, + user_id, + last_read_at +) VALUES ( + $1, + $2, + $3 +) +ON CONFLICT (thread_id, user_id) DO NOTHING; + +-- name: CreateThreadMessage :one +INSERT INTO messages ( + thread_id, + sender_user_id, + body +) VALUES ( + $1, + $2, + $3 +) +RETURNING *; + +-- name: TouchMessageThread :exec +UPDATE message_threads +SET updated_at = NOW() +WHERE id = $1; + +-- name: UpdateMessageThreadSubject :one +UPDATE message_threads +SET subject = sqlc.arg(subject), + updated_at = NOW() +WHERE id = sqlc.arg(thread_id) +RETURNING *; + +-- name: UpdateThreadMessageBody :one +UPDATE messages +SET body = sqlc.arg(body), + updated_at = NOW() +WHERE id = sqlc.arg(message_id) + AND thread_id = sqlc.arg(thread_id) + AND sender_user_id = sqlc.arg(user_id) +RETURNING *; + +-- name: DeleteThreadMessage :one +DELETE FROM messages +WHERE id = sqlc.arg(message_id) + AND thread_id = sqlc.arg(thread_id) + AND sender_user_id = sqlc.arg(user_id) +RETURNING *; + +-- name: DeleteMessageThread :one +DELETE FROM message_threads +WHERE id = sqlc.arg(thread_id) +RETURNING *; + +-- name: MarkMessageThreadRead :one +UPDATE message_thread_participants +SET last_read_at = COALESCE((SELECT MAX(m.created_at) FROM messages m WHERE m.thread_id = $1), NOW()) +WHERE message_thread_participants.thread_id = $1 + AND message_thread_participants.user_id = $2 +RETURNING *; diff --git a/Backend/db/queries/questions.sql b/Backend/db/queries/questions.sql new file mode 100644 index 0000000..160c564 --- /dev/null +++ b/Backend/db/queries/questions.sql @@ -0,0 +1,55 @@ +-- name: CreateQuestion :one +INSERT INTO questions ( + author_teacher_id, + title, + prompt, + topic, + subject, + difficulty, + source, + status, + correct_answer +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9 +) +RETURNING *; + +-- name: ListQuestionsByTeacher :many +SELECT * +FROM questions +WHERE author_teacher_id = $1 +ORDER BY created_at DESC; + +-- name: GetQuestionByID :one +SELECT * +FROM questions +WHERE id = $1; + +-- name: CreateTag :one +INSERT INTO tags (name) +VALUES ($1) +ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name +RETURNING *; + +-- name: AttachTagToQuestion :exec +INSERT INTO question_tags ( + question_id, + tag_id +) VALUES ( + $1, + $2 +) +ON CONFLICT (question_id, tag_id) DO NOTHING; + +-- name: ListTags :many +SELECT * +FROM tags +ORDER BY name ASC; diff --git a/Backend/db/queries/student_answers.sql b/Backend/db/queries/student_answers.sql new file mode 100644 index 0000000..e38ca64 --- /dev/null +++ b/Backend/db/queries/student_answers.sql @@ -0,0 +1,228 @@ +-- name: UpsertStudentAnswer :one +INSERT INTO student_answers ( + assignment_id, + question_id, + student_id, + answer_text, + solve_mode, + working_steps, + ai_feedback, + teacher_feedback, + status, + submitted_at, + reviewed_at, + is_correct +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + $11, + $12 +) +ON CONFLICT (assignment_id, question_id, student_id) DO UPDATE +SET + answer_text = EXCLUDED.answer_text, + solve_mode = EXCLUDED.solve_mode, + working_steps = EXCLUDED.working_steps, + ai_feedback = EXCLUDED.ai_feedback, + teacher_feedback = EXCLUDED.teacher_feedback, + status = EXCLUDED.status, + submitted_at = EXCLUDED.submitted_at, + reviewed_at = EXCLUDED.reviewed_at, + is_correct = EXCLUDED.is_correct, + updated_at = NOW() +RETURNING *; + +-- name: UpdateAnswerAIReview :one +UPDATE student_answers +SET + ai_feedback = $2, + review_needs_attention = $3, + review_issue_reason = $4, + review_correctness_score = $5, + review_understanding_score = $6, + review_question_score = $7, + review_confidence = $8, + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: ListAnswersForAssignment :many +SELECT * +FROM student_answers +WHERE assignment_id = $1 +ORDER BY created_at ASC; + +-- name: ListAnswersForStudent :many +SELECT * +FROM student_answers +WHERE student_id = $1 +ORDER BY created_at DESC; + +-- name: ListQuestionDetailsForAssignmentStudent :many +WITH student_question_set AS ( + SELECT + asq.assignment_id, + asq.question_id, + asq.position + FROM assignment_student_questions asq + WHERE asq.assignment_id = $1 + AND asq.student_id = $2 +), +selected_questions AS ( + SELECT + sq.assignment_id, + sq.question_id, + sq.position + FROM student_question_set sq + UNION ALL + SELECT + aq.assignment_id, + aq.question_id, + aq.position + FROM assignment_questions aq + WHERE aq.assignment_id = $1 + AND NOT EXISTS (SELECT 1 FROM student_question_set) +) +SELECT + aq.assignment_id, + aq.question_id, + aq.position, + q.title, + q.prompt, + q.subject, + q.source, + COALESCE( + ARRAY( + SELECT t.name + FROM question_tags qt + JOIN tags t ON t.id = qt.tag_id + WHERE qt.question_id = aq.question_id + ORDER BY t.name ASC + ), + ARRAY[]::TEXT[] + )::TEXT[] AS question_tags, + q.status AS question_status, + q.correct_answer, + aa.ai_feedback AS assignment_ai_feedback, + aa.teacher_feedback AS assignment_teacher_feedback, + review_summary.overall_score, + a.pass_threshold, + aa.next_step_outcome, + aa.pass_status_override, + COALESCE( + aa.pass_status_override, + CASE + WHEN review_summary.overall_score IS NULL THEN 'pending'::assignment_pass_status + WHEN review_summary.overall_score >= a.pass_threshold THEN 'pass'::assignment_pass_status + ELSE 'no_pass'::assignment_pass_status + END + ) AS pass_status, + sa.id AS answer_id, + sa.student_id, + sa.answer_text, + sa.solve_mode, + sa.working_steps, + sa.is_correct, + sa.ai_feedback, + sa.teacher_feedback, + sa.status AS answer_status, + sa.review_needs_attention, + sa.review_issue_reason, + sa.review_correctness_score, + sa.review_understanding_score, + sa.review_question_score, + sa.review_confidence, + sa.review_tags, + sa.submitted_at, + sa.reviewed_at, + sa.created_at AS answer_created_at, + sa.updated_at AS answer_updated_at + FROM selected_questions aq + JOIN assignments a ON a.id = aq.assignment_id + JOIN questions q ON q.id = aq.question_id + LEFT JOIN assignment_assignees aa + ON aa.assignment_id = aq.assignment_id + AND aa.student_id = $2 + LEFT JOIN LATERAL ( + SELECT CASE + WHEN COUNT(sa2.id) = 0 THEN NULL::NUMERIC(5,2) + ELSE ROUND((AVG( + CASE + WHEN sa2.is_correct IS NULL THEN COALESCE(sa2.review_understanding_score, 0)::NUMERIC + ELSE ( + ((CASE WHEN sa2.is_correct THEN 1 ELSE 0 END)::NUMERIC) + COALESCE(sa2.review_understanding_score, 0)::NUMERIC + ) / 2 + END + ) * 10)::NUMERIC, 2)::NUMERIC(5,2) + END AS overall_score + FROM selected_questions aq2 + LEFT JOIN student_answers sa2 + ON sa2.assignment_id = aq2.assignment_id + AND sa2.question_id = aq2.question_id + AND sa2.student_id = $2 + WHERE aq2.assignment_id = aq.assignment_id +) review_summary ON TRUE +LEFT JOIN student_answers sa + ON sa.assignment_id = aq.assignment_id + AND sa.question_id = aq.question_id + AND sa.student_id = $2 +WHERE aq.assignment_id = $1 +ORDER BY aq.position ASC, aq.question_id ASC; + +-- name: UpdateAnswerReview :one +UPDATE student_answers +SET + status = $2, + review_needs_attention = $3, + review_issue_reason = $4, + review_correctness_score = $5, + review_understanding_score = $6, + review_question_score = $7, + review_confidence = $8, + review_tags = $9, + reviewed_at = CASE + WHEN $2::answer_status = 'reviewed' THEN NOW() + ELSE NULL + END, + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: ListStudentPlanningPerformance :many +SELECT + sa.assignment_id, + sa.question_id, + q.topic, + q.subject, + q.difficulty, + COALESCE( + ARRAY( + SELECT t.name + FROM question_tags qt + JOIN tags t ON t.id = qt.tag_id + WHERE qt.question_id = sa.question_id + ORDER BY t.name ASC + ), + ARRAY[]::TEXT[] + )::TEXT[] AS question_tags, + sa.is_correct, + sa.review_understanding_score, + sa.review_needs_attention, + sa.review_issue_reason, + sa.status, + sa.submitted_at, + sa.reviewed_at, + sa.updated_at +FROM student_answers sa +JOIN questions q ON q.id = sa.question_id +WHERE sa.student_id = $1 + AND sa.status IN ('submitted'::answer_status, 'reviewed'::answer_status) +ORDER BY COALESCE(sa.reviewed_at, sa.submitted_at, sa.updated_at) DESC, sa.id DESC; diff --git a/Backend/db/queries/users.sql b/Backend/db/queries/users.sql new file mode 100644 index 0000000..6541992 --- /dev/null +++ b/Backend/db/queries/users.sql @@ -0,0 +1,178 @@ +-- name: CreateUser :one +INSERT INTO users ( + email, + password_hash, + role, + full_name +) VALUES ( + $1, + $2, + $3, + $4 +) +RETURNING *; + +-- name: GetUserByID :one +SELECT * +FROM users +WHERE id = $1; + +-- name: GetAuthUserByID :one +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + u.is_active AS user_is_active, + u.created_at AS user_created_at, + u.updated_at AS user_updated_at, + p.user_id AS profile_user_id, + p.preferred_name, + p.profile_icon_url, + p.headline, + p.bio, + p.timezone, + p.locale, + p.grade_level, + p.learning_goal, + p.created_at AS profile_created_at, + p.updated_at AS profile_updated_at +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.id = $1; + +-- name: GetUserByEmail :one +SELECT * +FROM users +WHERE email = $1; + +-- name: GetAuthUserByEmail :one +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + u.is_active AS user_is_active, + u.password_hash AS user_password_hash, + u.created_at AS user_created_at, + u.updated_at AS user_updated_at, + p.user_id AS profile_user_id, + p.preferred_name, + p.profile_icon_url, + p.headline, + p.bio, + p.timezone, + p.locale, + p.grade_level, + p.learning_goal, + p.created_at AS profile_created_at, + p.updated_at AS profile_updated_at +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.email = $1; + +-- name: ListUsersByRole :many +SELECT * +FROM users +WHERE role = $1 +ORDER BY full_name ASC; + +-- name: ListUsersWithProfileByRole :many +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + u.is_active AS user_is_active, + u.created_at AS user_created_at, + u.updated_at AS user_updated_at, + p.user_id AS profile_user_id, + p.preferred_name, + p.profile_icon_url, + p.headline, + p.bio, + p.timezone, + p.locale, + p.grade_level, + p.learning_goal, + p.created_at AS profile_created_at, + p.updated_at AS profile_updated_at +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.role = $1 +ORDER BY u.full_name ASC; + +-- name: GetUserWithProfileByID :one +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + u.is_active AS user_is_active, + u.created_at AS user_created_at, + u.updated_at AS user_updated_at, + p.user_id AS profile_user_id, + p.preferred_name, + p.profile_icon_url, + p.headline, + p.bio, + p.timezone, + p.locale, + p.grade_level, + p.learning_goal, + p.created_at AS profile_created_at, + p.updated_at AS profile_updated_at +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.id = $1; + +-- name: UpdateUserActiveStatus :one +UPDATE users +SET + is_active = $2, + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: UpdateUserFullName :one +UPDATE users +SET + full_name = $2, + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: UpsertUserProfile :one +INSERT INTO profiles ( + user_id, + preferred_name, + profile_icon_url, + headline, + bio, + timezone, + locale, + grade_level, + learning_goal +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9 +) +ON CONFLICT (user_id) DO UPDATE +SET + preferred_name = EXCLUDED.preferred_name, + profile_icon_url = EXCLUDED.profile_icon_url, + headline = EXCLUDED.headline, + bio = EXCLUDED.bio, + timezone = EXCLUDED.timezone, + locale = EXCLUDED.locale, + grade_level = EXCLUDED.grade_level, + learning_goal = EXCLUDED.learning_goal, + updated_at = NOW() +RETURNING *; diff --git a/Backend/db/sqlc.yaml b/Backend/db/sqlc.yaml new file mode 100644 index 0000000..01f5f0a --- /dev/null +++ b/Backend/db/sqlc.yaml @@ -0,0 +1,12 @@ +version: "2" +sql: + - engine: "postgresql" + queries: "queries/" + schema: "migrations/" + gen: + go: + package: "sqlc" + out: "../internal/sqlc" + sql_package: "pgx/v5" + emit_json_tags: true + emit_empty_slices: true diff --git a/Backend/go.mod b/Backend/go.mod new file mode 100644 index 0000000..55f381d --- /dev/null +++ b/Backend/go.mod @@ -0,0 +1,33 @@ +module boostai-backend + +go 1.24.11 + +require ( + github.com/gofiber/fiber/v2 v2.52.10 + github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/jackc/pgx/v5 v5.8.0 + github.com/pressly/goose/v3 v3.26.0 + golang.org/x/crypto v0.40.0 +) + +require ( + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect +) diff --git a/Backend/go.sum b/Backend/go.sum new file mode 100644 index 0000000..82b1277 --- /dev/null +++ b/Backend/go.sum @@ -0,0 +1,83 @@ +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY= +github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= +github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= diff --git a/Backend/internal/aireview/service.go b/Backend/internal/aireview/service.go new file mode 100644 index 0000000..c404373 --- /dev/null +++ b/Backend/internal/aireview/service.go @@ -0,0 +1,469 @@ +package aireview + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type Service struct { + endpoint string + apiKey string + model string + client *http.Client +} + +type AssignmentReviewInput struct { + AssignmentID int64 `json:"assignmentId"` + StudentID int64 `json:"studentId"` + AssignmentTitle string `json:"assignmentTitle"` + Instructions string `json:"instructions,omitempty"` + PassThreshold float64 `json:"passThreshold"` + Questions []AssignmentQuestionInput `json:"questions"` +} + +type AssignmentQuestionInput struct { + QuestionID int64 `json:"questionId"` + Position int32 `json:"position"` + Title string `json:"title"` + Prompt string `json:"prompt"` + Subject string `json:"subject,omitempty"` + Source string `json:"source,omitempty"` + CorrectAnswer string `json:"correctAnswer,omitempty"` + QuestionTags []string `json:"questionTags,omitempty"` + SolveMode string `json:"solveMode,omitempty"` + AnswerText string `json:"answerText,omitempty"` + WorkingSteps string `json:"workingSteps,omitempty"` + IsCorrect *bool `json:"isCorrect,omitempty"` + AnswerStatus string `json:"answerStatus,omitempty"` +} + +type AssignmentReviewResult struct { + Questions []QuestionReviewResult `json:"questions"` + AssignmentSummary string `json:"assignmentSummary"` + RecommendedNextStep string `json:"recommendedNextStep"` +} + +type RedoPlanInput struct { + AssignmentID int64 `json:"assignmentId"` + StudentID int64 `json:"studentId"` + AssignmentTitle string `json:"assignmentTitle"` + Instructions string `json:"instructions,omitempty"` + TeacherFeedback string `json:"teacherFeedback,omitempty"` + PassThreshold float64 `json:"passThreshold"` + TopicScores map[string]float64 `json:"topicScores"` + WeakTags []string `json:"weakTags,omitempty"` + RecentIssues []string `json:"recentIssues,omitempty"` + AllowedTopics []string `json:"allowedTopics"` + AllowedDifficulties []string `json:"allowedDifficulties"` +} + +type RedoPlanResult struct { + Rationale string `json:"rationale"` + QuestionSet []RedoPlanQuestion `json:"questionSet"` +} + +type RedoPlanQuestion struct { + Topic string `json:"topic"` + Difficulty string `json:"difficulty"` + Tags []string `json:"tags,omitempty"` + Reason string `json:"reason"` +} + +type QuestionReviewResult struct { + QuestionID int64 `json:"questionId"` + AiFeedback string `json:"aiFeedback"` + UnderstandingScore float64 `json:"understandingScore"` + Confidence float64 `json:"confidence"` + NeedsAttention bool `json:"needsAttention"` + IssueReason string `json:"issueReason"` +} + +func NewService(endpoint, apiKey, model string) *Service { + return &Service{ + endpoint: strings.TrimSpace(endpoint), + apiKey: strings.TrimSpace(apiKey), + model: strings.TrimSpace(model), + client: &http.Client{ + Timeout: 45 * time.Second, + }, + } +} + +func (s *Service) Enabled() bool { + return s != nil && s.endpoint != "" && s.apiKey != "" && s.model != "" +} + +func (s *Service) ReviewSubmission(ctx context.Context, input AssignmentReviewInput) (*AssignmentReviewResult, error) { + if !s.Enabled() { + return nil, fmt.Errorf("AI review is not configured") + } + + payloadJSON, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("marshal AI review input: %w", err) + } + + body := map[string]any{ + "model": s.model, + "input": []map[string]any{ + { + "role": "system", + "content": []map[string]any{{ + "type": "input_text", + "text": strings.TrimSpace(`You are reviewing student homework submissions for a teacher workflow. + +You must assess the student's understanding by looking at the student's final answer and working against the saved correct answer when one is available. Do not re-grade weighting. + +Rules: +- correctness score is fixed at 1.0 externally and must not vary. +- question weighting is fixed at 1.0 externally and must not vary. +- understandingScore is the only variable score and must be between 0.0 and 1.0. +- confidence must be between 0.0 and 1.0. +- every question in the assignment must be included in the output, even if the student left it blank. +- if answerText and workingSteps are both empty, treat the question as unanswered and set understandingScore to 0.0. +- unanswered questions should normally set needsAttention to true and explain that no answer was submitted. +- when correctAnswer is present, explicitly compare the student's answerText and workingSteps against that correctAnswer before judging understanding. +- when the student's answer is wrong, issueReason should say what is mismatched, missing, or misunderstood relative to the correctAnswer, not just give a generic comment. +- when the student's answer is correct but the explanation is weak, issueReason should make clear that correctness was reached but understanding evidence is still limited. +- when correctAnswer is missing, fall back to judging from the student's explanation, steps, and internal consistency only. +- needsAttention should be true when the student likely needs follow-up help based on their understanding, explanation quality, or uncertainty. +- issueReason should be concise and directly tied to understanding gaps, explicitly grounded in the comparison to the correct answer whenever available. +- aiFeedback should be concise, teacher-facing, and about the student's understanding for that question, referencing the answer-vs-correct-answer comparison when it materially explains the judgment. +- recommendedNextStep must be exactly one of: redo, accept, support. + +Interpretation guidance: +- accept = understanding is generally sufficient for the assignment so the student can continue. +- support = the student shows meaningful gaps and likely needs targeted help. +- redo = the student should redo the assignment because understanding is broadly too weak or incomplete. + + Review the full assignment in one pass and produce a short assignment-level summary.`), + }}, + }, + { + "role": "user", + "content": []map[string]any{{ + "type": "input_text", + "text": string(payloadJSON), + }}, + }, + }, + "text": map[string]any{ + "format": map[string]any{ + "type": "json_schema", + "name": "assignment_review", + "strict": true, + "schema": reviewSchema(), + }, + }, + } + + bodyBytes, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal AI review request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.endpoint, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("build AI review request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("api-key", s.apiKey) + + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("send AI review request: %w", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read AI review response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("AI review request failed: status %d body %s", resp.StatusCode, strings.TrimSpace(string(respBytes))) + } + + outputText, err := extractOutputText(respBytes) + if err != nil { + return nil, err + } + + var result AssignmentReviewResult + if err := json.Unmarshal([]byte(outputText), &result); err != nil { + return nil, fmt.Errorf("decode AI review structured output: %w", err) + } + + sanitizeResult(&result) + return &result, nil +} + +func (s *Service) PlanRedoAssignment(ctx context.Context, input RedoPlanInput) (*RedoPlanResult, error) { + if !s.Enabled() { + return nil, fmt.Errorf("AI review is not configured") + } + + payloadJSON, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("marshal redo plan input: %w", err) + } + + body := map[string]any{ + "model": s.model, + "input": []map[string]any{ + { + "role": "system", + "content": []map[string]any{{ + "type": "input_text", + "text": strings.TrimSpace(`You are planning the next redo assignment for a student. + +You are NOT writing final math questions. You are only producing a structured topic+difficulty blueprint for a later generator layer. + +Rules: +- Use only the allowedTopics values exactly as provided. +- Use only the allowedDifficulties values exactly as provided. +- Return between 5 and 10 planned items. +- Focus most heavily on the weakest topics and weak tags, while still keeping the redo assignment coherent with the current assignment title/instructions and any teacher feedback. +- Prefer a sensible progression of difficulty rather than making everything hard. +- If teacherFeedback contains a specific reteach direction, incorporate it. +- Tags should be short machine-friendly labels and may be empty. +- rationale should briefly explain why these topics/difficulties were chosen from the weakness summary. +- reason on each item should briefly explain why that topic/difficulty belongs in the redo set. +- Do not invent topics outside the allowed topic vocabulary. +- Do not output prose outside the JSON schema.`), + }}, + }, + { + "role": "user", + "content": []map[string]any{{ + "type": "input_text", + "text": string(payloadJSON), + }}, + }, + }, + "text": map[string]any{ + "format": map[string]any{ + "type": "json_schema", + "name": "redo_assignment_plan", + "strict": true, + "schema": redoPlanSchema(), + }, + }, + } + + bodyBytes, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal redo plan request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.endpoint, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("build redo plan request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("api-key", s.apiKey) + + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("send redo plan request: %w", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read redo plan response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("redo plan request failed: status %d body %s", resp.StatusCode, strings.TrimSpace(string(respBytes))) + } + + outputText, err := extractOutputText(respBytes) + if err != nil { + return nil, err + } + + var result RedoPlanResult + if err := json.Unmarshal([]byte(outputText), &result); err != nil { + return nil, fmt.Errorf("decode redo plan structured output: %w", err) + } + + sanitizeRedoPlan(&result, input.AllowedTopics, input.AllowedDifficulties) + return &result, nil +} + +func reviewSchema() map[string]any { + return map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "questions": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "questionId": map[string]any{"type": "integer"}, + "aiFeedback": map[string]any{"type": "string"}, + "understandingScore": map[string]any{"type": "number", "minimum": 0, "maximum": 1}, + "confidence": map[string]any{"type": "number", "minimum": 0, "maximum": 1}, + "needsAttention": map[string]any{"type": "boolean"}, + "issueReason": map[string]any{"type": "string"}, + }, + "required": []string{"questionId", "aiFeedback", "understandingScore", "confidence", "needsAttention", "issueReason"}, + }, + }, + "assignmentSummary": map[string]any{"type": "string"}, + "recommendedNextStep": map[string]any{"type": "string", "enum": []string{"redo", "accept", "support"}}, + }, + "required": []string{"questions", "assignmentSummary", "recommendedNextStep"}, + } +} + +func redoPlanSchema() map[string]any { + return map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "rationale": map[string]any{"type": "string"}, + "questionSet": map[string]any{ + "type": "array", + "minItems": 5, + "maxItems": 10, + "items": map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "topic": map[string]any{"type": "string"}, + "difficulty": map[string]any{"type": "string"}, + "tags": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + }, + "reason": map[string]any{"type": "string"}, + }, + "required": []string{"topic", "difficulty", "tags", "reason"}, + }, + }, + }, + "required": []string{"rationale", "questionSet"}, + } +} + +func extractOutputText(respBytes []byte) (string, error) { + var direct struct { + OutputText string `json:"output_text"` + } + if err := json.Unmarshal(respBytes, &direct); err == nil { + if text := strings.TrimSpace(direct.OutputText); text != "" { + return text, nil + } + } + + var raw map[string]any + if err := json.Unmarshal(respBytes, &raw); err != nil { + return "", fmt.Errorf("decode AI review raw response: %w", err) + } + + if text := strings.TrimSpace(findOutputText(raw)); text != "" { + return text, nil + } + + return "", fmt.Errorf("AI review response did not contain structured output text") +} + +func findOutputText(value any) string { + switch typed := value.(type) { + case map[string]any: + if text, ok := typed["output_text"].(string); ok && strings.TrimSpace(text) != "" { + return text + } + if text, ok := typed["text"].(string); ok && strings.TrimSpace(text) != "" { + return text + } + for _, nested := range typed { + if result := findOutputText(nested); result != "" { + return result + } + } + case []any: + for _, nested := range typed { + if result := findOutputText(nested); result != "" { + return result + } + } + } + + return "" +} + +func sanitizeResult(result *AssignmentReviewResult) { + result.AssignmentSummary = strings.TrimSpace(result.AssignmentSummary) + switch result.RecommendedNextStep { + case "redo", "accept", "support": + default: + result.RecommendedNextStep = "support" + } + + for index := range result.Questions { + question := &result.Questions[index] + question.AiFeedback = strings.TrimSpace(question.AiFeedback) + question.IssueReason = strings.TrimSpace(question.IssueReason) + question.UnderstandingScore = clamp01(question.UnderstandingScore) + question.Confidence = clamp01(question.Confidence) + } +} + +func sanitizeRedoPlan(result *RedoPlanResult, allowedTopics []string, allowedDifficulties []string) { + allowedTopicSet := make(map[string]struct{}, len(allowedTopics)) + for _, topic := range allowedTopics { + allowedTopicSet[strings.TrimSpace(topic)] = struct{}{} + } + allowedDifficultySet := make(map[string]struct{}, len(allowedDifficulties)) + for _, difficulty := range allowedDifficulties { + allowedDifficultySet[strings.TrimSpace(difficulty)] = struct{}{} + } + + result.Rationale = strings.TrimSpace(result.Rationale) + filtered := make([]RedoPlanQuestion, 0, len(result.QuestionSet)) + for _, item := range result.QuestionSet { + item.Topic = strings.TrimSpace(item.Topic) + item.Difficulty = strings.ToLower(strings.TrimSpace(item.Difficulty)) + if _, ok := allowedTopicSet[item.Topic]; !ok { + continue + } + if _, ok := allowedDifficultySet[item.Difficulty]; !ok { + continue + } + item.Reason = strings.TrimSpace(item.Reason) + cleanTags := make([]string, 0, len(item.Tags)) + for _, tag := range item.Tags { + tag = strings.TrimSpace(tag) + if tag == "" { + continue + } + cleanTags = append(cleanTags, tag) + } + item.Tags = cleanTags + filtered = append(filtered, item) + } + result.QuestionSet = filtered +} + +func clamp01(value float64) float64 { + if value < 0 { + return 0 + } + if value > 1 { + return 1 + } + return value +} diff --git a/Backend/internal/assignmentgen/personalization.go b/Backend/internal/assignmentgen/personalization.go new file mode 100644 index 0000000..506df07 --- /dev/null +++ b/Backend/internal/assignmentgen/personalization.go @@ -0,0 +1,106 @@ +package assignmentgen + +import ( + "context" + "fmt" + + "boostai-backend/internal/sqlc" +) + +const defaultPersonalizedRatio = 0.30 + +type WeaknessSummary struct { + TopicScores map[sqlc.QuestionTopic]float64 + WeakTags []string + RecentIssues []string +} + +type MixedPlanParams struct { + StudentID int64 + PrimaryTopic sqlc.QuestionTopic + PrimaryDifficulty sqlc.QuestionDifficulty + TotalQuestions int + PersonalizedRatio float64 + BaseSeed int64 + PersonalizedDifficulty sqlc.QuestionDifficulty +} + +type MixedPlan struct { + Plan []PlanItem + WeaknessSummary WeaknessSummary + CoreCount int + PersonalizedCount int + PersonalizedTopic sqlc.QuestionTopic + PersonalizedApplied bool + BaseSeed int64 +} + +type GenerateMixedStudentQuestionSetParams struct { + AssignmentID int64 + StudentID int64 + TeacherID int64 + Subject string + QuestionStatus sqlc.QuestionStatus + QuestionSource string + PrimaryTopic sqlc.QuestionTopic + PrimaryDifficulty sqlc.QuestionDifficulty + TotalQuestions int + PersonalizedRatio float64 + Seed int64 + PersonalizedDifficulty sqlc.QuestionDifficulty +} + +type GenerateMixedStudentQuestionSetResult struct { + StoredQuestions []StoredStudentQuestion + MixedPlan MixedPlan +} + +func (s *Service) BuildWeaknessSummary(ctx context.Context, studentID int64) (WeaknessSummary, error) { + if s == nil || s.db == nil || s.db.Pool == nil { + return WeaknessSummary{}, fmt.Errorf("assignment question generator database is not configured") + } + if studentID <= 0 { + return WeaknessSummary{}, fmt.Errorf("student_id is required") + } + + queries := sqlc.New(s.db.Pool) + rows, err := queries.ListStudentPlanningPerformance(ctx, studentID) + if err != nil { + return WeaknessSummary{}, err + } + + return buildWeaknessSummary(rows), nil +} + +func (s *Service) GenerateAndStoreMixedStudentQuestions(ctx context.Context, params GenerateMixedStudentQuestionSetParams) (GenerateMixedStudentQuestionSetResult, error) { + mixedPlan, err := s.BuildMixedPlan(ctx, MixedPlanParams{ + StudentID: params.StudentID, + PrimaryTopic: params.PrimaryTopic, + PrimaryDifficulty: params.PrimaryDifficulty, + TotalQuestions: params.TotalQuestions, + PersonalizedRatio: params.PersonalizedRatio, + BaseSeed: params.Seed, + PersonalizedDifficulty: params.PersonalizedDifficulty, + }) + if err != nil { + return GenerateMixedStudentQuestionSetResult{}, err + } + + storedQuestions, err := s.GenerateAndStoreStudentQuestions(ctx, GenerateStudentQuestionSetParams{ + AssignmentID: params.AssignmentID, + StudentID: params.StudentID, + TeacherID: params.TeacherID, + Subject: params.Subject, + QuestionStatus: params.QuestionStatus, + QuestionSource: params.QuestionSource, + Plan: mixedPlan.Plan, + }) + if err != nil { + return GenerateMixedStudentQuestionSetResult{}, err + } + + return GenerateMixedStudentQuestionSetResult{ + StoredQuestions: storedQuestions, + MixedPlan: mixedPlan, + }, nil +} diff --git a/Backend/internal/assignmentgen/personalization_plan.go b/Backend/internal/assignmentgen/personalization_plan.go new file mode 100644 index 0000000..7105fb5 --- /dev/null +++ b/Backend/internal/assignmentgen/personalization_plan.go @@ -0,0 +1,172 @@ +package assignmentgen + +import ( + "context" + "fmt" + "math" + "sort" + "time" + + "boostai-backend/internal/sqlc" +) + +func (s *Service) BuildMixedPlan(ctx context.Context, params MixedPlanParams) (MixedPlan, error) { + if err := validateMixedPlanParams(params); err != nil { + return MixedPlan{}, err + } + + ratio := normalizePersonalizedRatio(params.PersonalizedRatio) + + weaknessSummary, err := s.BuildWeaknessSummary(ctx, params.StudentID) + if err != nil { + return MixedPlan{}, err + } + + personalizedTopic, personalizedApplied := selectPersonalizedTopic(params.PrimaryTopic, weaknessSummary) + personalizedCount := calculatePersonalizedCount(params.TotalQuestions, ratio, personalizedApplied) + coreCount := calculateCoreCount(params.TotalQuestions, personalizedCount) + baseSeed := normalizeBaseSeed(params.BaseSeed) + personalizedDifficulty := normalizePersonalizedDifficulty(params) + + return MixedPlan{ + Plan: buildMixedPlanItems(params, coreCount, personalizedCount, personalizedTopic, personalizedDifficulty, baseSeed), + WeaknessSummary: weaknessSummary, + CoreCount: coreCount, + PersonalizedCount: personalizedCount, + PersonalizedTopic: personalizedTopic, + PersonalizedApplied: personalizedCount > 0, + BaseSeed: baseSeed, + }, nil +} + +func validateMixedPlanParams(params MixedPlanParams) error { + if params.StudentID <= 0 { + return fmt.Errorf("student_id is required") + } + if params.PrimaryTopic == "" { + return fmt.Errorf("primary topic is required") + } + if params.PrimaryDifficulty == "" { + return fmt.Errorf("primary difficulty is required") + } + if params.TotalQuestions <= 0 { + return fmt.Errorf("total_questions must be positive") + } + if params.PersonalizedRatio < 0 || params.PersonalizedRatio >= 1 { + return fmt.Errorf("personalized_ratio must be between 0 and less than 1") + } + + return nil +} + +func normalizePersonalizedRatio(ratio float64) float64 { + if ratio == 0 { + return defaultPersonalizedRatio + } + return ratio +} + +func normalizeBaseSeed(seed int64) int64 { + if seed == 0 { + return time.Now().UnixNano() + } + return seed +} + +func normalizePersonalizedDifficulty(params MixedPlanParams) sqlc.QuestionDifficulty { + if params.PersonalizedDifficulty == "" { + return params.PrimaryDifficulty + } + return params.PersonalizedDifficulty +} + +func calculateCoreCount(totalQuestions, personalizedCount int) int { + coreCount := totalQuestions - personalizedCount + if totalQuestions > 0 && coreCount <= 0 { + return 1 + } + return coreCount +} + +func buildMixedPlanItems( + params MixedPlanParams, + coreCount int, + personalizedCount int, + personalizedTopic sqlc.QuestionTopic, + personalizedDifficulty sqlc.QuestionDifficulty, + baseSeed int64, +) []PlanItem { + plan := make([]PlanItem, 0, 2) + + if coreCount > 0 { + plan = append(plan, PlanItem{ + Topic: params.PrimaryTopic, + Difficulty: params.PrimaryDifficulty, + Count: coreCount, + SourceBucket: SourceBucketCoreTopic, + Seed: baseSeed, + }) + } + + if personalizedCount > 0 { + plan = append(plan, PlanItem{ + Topic: personalizedTopic, + Difficulty: personalizedDifficulty, + Count: personalizedCount, + SourceBucket: SourceBucketPersonalized, + Seed: baseSeed + 7919, + }) + } + + return plan +} + +func selectPersonalizedTopic(primaryTopic sqlc.QuestionTopic, summary WeaknessSummary) (sqlc.QuestionTopic, bool) { + if len(summary.TopicScores) == 0 { + return "", false + } + + type scoredTopic struct { + topic sqlc.QuestionTopic + score float64 + } + + topics := make([]scoredTopic, 0, len(summary.TopicScores)) + for topic, score := range summary.TopicScores { + topics = append(topics, scoredTopic{topic: topic, score: score}) + } + + sort.SliceStable(topics, func(i, j int) bool { + if topics[i].score == topics[j].score { + return string(topics[i].topic) < string(topics[j].topic) + } + return topics[i].score < topics[j].score + }) + + for _, candidate := range topics { + if candidate.topic != primaryTopic { + return candidate.topic, true + } + } + + return topics[0].topic, true +} + +func calculatePersonalizedCount(totalQuestions int, ratio float64, personalizedApplied bool) int { + if !personalizedApplied || totalQuestions <= 1 || ratio <= 0 { + return 0 + } + + count := int(math.Floor(float64(totalQuestions) * ratio)) + if count == 0 && totalQuestions >= 3 { + count = 1 + } + if count >= totalQuestions { + count = totalQuestions - 1 + } + if count < 0 { + count = 0 + } + + return count +} diff --git a/Backend/internal/assignmentgen/personalization_test.go b/Backend/internal/assignmentgen/personalization_test.go new file mode 100644 index 0000000..06a718e --- /dev/null +++ b/Backend/internal/assignmentgen/personalization_test.go @@ -0,0 +1,85 @@ +package assignmentgen + +import ( + "fmt" + "testing" + + "boostai-backend/internal/sqlc" + + "github.com/jackc/pgx/v5/pgtype" +) + +func TestBuildWeaknessSummaryAggregatesSignals(t *testing.T) { + rows := []sqlc.ListStudentPlanningPerformanceRow{ + { + Topic: sqlc.NullQuestionTopic{QuestionTopic: sqlc.QuestionTopicFractions, Valid: true}, + QuestionTags: []string{"fractions", "simplify"}, + IsCorrect: pgtype.Bool{Bool: false, Valid: true}, + ReviewUnderstandingScore: mustNumeric(t, 0.2), + ReviewNeedsAttention: true, + ReviewIssueReason: pgtype.Text{String: "Missed simplification", Valid: true}, + }, + { + Topic: sqlc.NullQuestionTopic{QuestionTopic: sqlc.QuestionTopicGeometry, Valid: true}, + QuestionTags: []string{"geometry", "angles"}, + IsCorrect: pgtype.Bool{Bool: true, Valid: true}, + ReviewUnderstandingScore: mustNumeric(t, 0.9), + ReviewIssueReason: pgtype.Text{String: "Explained angle sum well", Valid: true}, + }, + } + + summary := buildWeaknessSummary(rows) + + if got := summary.TopicScores[sqlc.QuestionTopicFractions]; got != 10 { + t.Fatalf("expected fractions score 10, got %v", got) + } + if got := summary.TopicScores[sqlc.QuestionTopicGeometry]; got != 95 { + t.Fatalf("expected geometry score 95, got %v", got) + } + if len(summary.WeakTags) == 0 || summary.WeakTags[0] != "fractions" { + t.Fatalf("expected fractions weak tag to rank first, got %#v", summary.WeakTags) + } + if len(summary.RecentIssues) != 2 { + t.Fatalf("expected 2 recent issues, got %d", len(summary.RecentIssues)) + } +} + +func TestSelectPersonalizedTopicPrefersWeakestNonPrimary(t *testing.T) { + summary := WeaknessSummary{ + TopicScores: map[sqlc.QuestionTopic]float64{ + sqlc.QuestionTopicFractions: 35, + sqlc.QuestionTopicGeometry: 82, + sqlc.QuestionTopicAlgebra: 61, + }, + } + + topic, ok := selectPersonalizedTopic(sqlc.QuestionTopicGeometry, summary) + if !ok { + t.Fatal("expected personalized topic to be selected") + } + if topic != sqlc.QuestionTopicFractions { + t.Fatalf("expected fractions, got %q", topic) + } +} + +func TestCalculatePersonalizedCountUsesSafeSplit(t *testing.T) { + if got := calculatePersonalizedCount(10, 0.3, true); got != 3 { + t.Fatalf("expected 3 personalized questions, got %d", got) + } + if got := calculatePersonalizedCount(2, 0.3, true); got != 0 { + t.Fatalf("expected 0 personalized questions for tiny set, got %d", got) + } + if got := calculatePersonalizedCount(5, 0.3, false); got != 0 { + t.Fatalf("expected 0 personalized questions without weakness topic, got %d", got) + } +} + +func mustNumeric(t *testing.T, value float64) pgtype.Numeric { + t.Helper() + + var numeric pgtype.Numeric + if err := numeric.ScanScientific(fmt.Sprintf("%f", value)); err != nil { + t.Fatalf("failed to build numeric: %v", err) + } + return numeric +} diff --git a/Backend/internal/assignmentgen/personalization_weakness.go b/Backend/internal/assignmentgen/personalization_weakness.go new file mode 100644 index 0000000..99583ad --- /dev/null +++ b/Backend/internal/assignmentgen/personalization_weakness.go @@ -0,0 +1,171 @@ +package assignmentgen + +import ( + "math" + "sort" + "strings" + + "boostai-backend/internal/sqlc" + + "github.com/jackc/pgx/v5/pgtype" +) + +func buildWeaknessSummary(rows []sqlc.ListStudentPlanningPerformanceRow) WeaknessSummary { + topicTotals := make(map[sqlc.QuestionTopic]float64) + topicCounts := make(map[sqlc.QuestionTopic]int) + tagStats := make(map[string]*tagWeaknessStats) + recentIssues := make([]string, 0, 5) + seenIssues := make(map[string]struct{}) + + for _, row := range rows { + score := planningScore(row.IsCorrect.Bool, numericFloat64OrZero(row.ReviewUnderstandingScore)) + accumulateTopicScore(topicTotals, topicCounts, row, score) + accumulateTagStats(tagStats, row, score) + recentIssues = appendRecentIssue(recentIssues, seenIssues, row.ReviewIssueReason.String) + if len(recentIssues) >= 5 { + break + } + } + + return WeaknessSummary{ + TopicScores: buildTopicScores(topicTotals, topicCounts), + WeakTags: collectWeakTags(tagStats), + RecentIssues: recentIssues, + } +} + +type tagWeaknessStats struct { + total float64 + count int + flaggedCount int +} + +type scoredTag struct { + tag string + score float64 + flaggedCount int +} + +func accumulateTopicScore( + topicTotals map[sqlc.QuestionTopic]float64, + topicCounts map[sqlc.QuestionTopic]int, + row sqlc.ListStudentPlanningPerformanceRow, + score float64, +) { + if !row.Topic.Valid { + return + } + + topicTotals[row.Topic.QuestionTopic] += score + topicCounts[row.Topic.QuestionTopic]++ +} + +func accumulateTagStats(tagStats map[string]*tagWeaknessStats, row sqlc.ListStudentPlanningPerformanceRow, score float64) { + for _, rawTag := range row.QuestionTags { + tag := strings.TrimSpace(strings.ToLower(rawTag)) + if tag == "" { + continue + } + + stats, ok := tagStats[tag] + if !ok { + stats = &tagWeaknessStats{} + tagStats[tag] = stats + } + + stats.total += score + stats.count++ + if row.ReviewNeedsAttention { + stats.flaggedCount++ + } + } +} + +func appendRecentIssue(recentIssues []string, seenIssues map[string]struct{}, rawIssue string) []string { + issue := strings.TrimSpace(rawIssue) + if issue == "" { + return recentIssues + } + if _, exists := seenIssues[issue]; exists { + return recentIssues + } + + seenIssues[issue] = struct{}{} + return append(recentIssues, issue) +} + +func buildTopicScores(topicTotals map[sqlc.QuestionTopic]float64, topicCounts map[sqlc.QuestionTopic]int) map[sqlc.QuestionTopic]float64 { + topicScores := make(map[sqlc.QuestionTopic]float64, len(topicTotals)) + for topic, total := range topicTotals { + count := topicCounts[topic] + if count == 0 { + continue + } + topicScores[topic] = roundToOneDecimal((total / float64(count)) * 100) + } + return topicScores +} + +func collectWeakTags(tagStats map[string]*tagWeaknessStats) []string { + if len(tagStats) == 0 { + return nil + } + + weakCandidates := make([]scoredTag, 0, len(tagStats)) + for tag, stats := range tagStats { + if stats == nil || stats.count == 0 { + continue + } + average := (stats.total / float64(stats.count)) * 100 + if average >= 70 && stats.flaggedCount == 0 { + continue + } + weakCandidates = append(weakCandidates, scoredTag{ + tag: tag, + score: roundToOneDecimal(average), + flaggedCount: stats.flaggedCount, + }) + } + + sort.SliceStable(weakCandidates, func(i, j int) bool { + if weakCandidates[i].score == weakCandidates[j].score { + if weakCandidates[i].flaggedCount == weakCandidates[j].flaggedCount { + return weakCandidates[i].tag < weakCandidates[j].tag + } + return weakCandidates[i].flaggedCount > weakCandidates[j].flaggedCount + } + return weakCandidates[i].score < weakCandidates[j].score + }) + + limit := 6 + if len(weakCandidates) < limit { + limit = len(weakCandidates) + } + + weakTags := make([]string, 0, limit) + for idx := 0; idx < limit; idx++ { + weakTags = append(weakTags, weakCandidates[idx].tag) + } + + return weakTags +} + +func planningScore(isCorrect bool, understandingScore float64) float64 { + correctness := 0.0 + if isCorrect { + correctness = 1.0 + } + return (correctness + understandingScore) / 2 +} + +func roundToOneDecimal(value float64) float64 { + return math.Round(value*10) / 10 +} + +func numericFloat64OrZero(value pgtype.Numeric) float64 { + floatValue, err := value.Float64Value() + if err != nil || !floatValue.Valid { + return 0 + } + return floatValue.Float64 +} diff --git a/Backend/internal/assignmentgen/service.go b/Backend/internal/assignmentgen/service.go new file mode 100644 index 0000000..8caa510 --- /dev/null +++ b/Backend/internal/assignmentgen/service.go @@ -0,0 +1,91 @@ +package assignmentgen + +import ( + "context" + + "boostai-backend/internal/database" + "boostai-backend/internal/questiongen" + "boostai-backend/internal/sqlc" +) + +type SourceBucket string + +const ( + SourceBucketCoreTopic SourceBucket = "core_topic" + SourceBucketPersonalized SourceBucket = "personalized" + defaultQuestionSource = "assignment_student_generated" +) + +type PlanItem struct { + Topic sqlc.QuestionTopic + Difficulty sqlc.QuestionDifficulty + Count int + SourceBucket SourceBucket + Seed int64 +} + +type GenerateStudentQuestionSetParams struct { + AssignmentID int64 + StudentID int64 + TeacherID int64 + Subject string + QuestionStatus sqlc.QuestionStatus + QuestionSource string + Plan []PlanItem +} + +type StoredStudentQuestion struct { + Mapping sqlc.AssignmentStudentQuestion + Question sqlc.Question + Tags []string + UsedSeed int64 + SourceBucket string +} + +type Service struct { + db *database.DB + generator *questiongen.Service +} + +func NewService(db *database.DB, generator *questiongen.Service) *Service { + return &Service{db: db, generator: generator} +} + +func (s *Service) GenerateAndStoreStudentQuestions(ctx context.Context, params GenerateStudentQuestionSetParams) ([]StoredStudentQuestion, error) { + if err := s.validateGenerateRequest(params); err != nil { + return nil, err + } + + tx, err := s.db.Pool.Begin(ctx) + if err != nil { + return nil, err + } + defer func() { + _ = tx.Rollback(ctx) + }() + + queries := sqlc.New(tx) + + if err := validateAssignmentOwnership(ctx, queries, params.AssignmentID, params.TeacherID); err != nil { + return nil, err + } + if err := validateStudentAssignment(ctx, queries, params.AssignmentID, params.StudentID); err != nil { + return nil, err + } + if err := clearStudentQuestionMappings(ctx, queries, params.AssignmentID, params.StudentID); err != nil { + return nil, err + } + + questionStatus, questionSource := normalizeQuestionDefaults(params) + + stored, err := s.generateAndStorePlan(ctx, queries, params, questionStatus, questionSource) + if err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return stored, nil +} diff --git a/Backend/internal/assignmentgen/service_generate.go b/Backend/internal/assignmentgen/service_generate.go new file mode 100644 index 0000000..70e4d46 --- /dev/null +++ b/Backend/internal/assignmentgen/service_generate.go @@ -0,0 +1,244 @@ +package assignmentgen + +import ( + "context" + "errors" + "fmt" + "strings" + + "boostai-backend/internal/questiongen" + "boostai-backend/internal/sqlc" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func (s *Service) validateGenerateRequest(params GenerateStudentQuestionSetParams) error { + if s == nil || s.db == nil || s.db.Pool == nil { + return errors.New("assignment question generator database is not configured") + } + if s.generator == nil { + return errors.New("assignment question generator is not configured") + } + if params.AssignmentID <= 0 || params.StudentID <= 0 || params.TeacherID <= 0 { + return errors.New("assignment_id, student_id, and teacher_id are required") + } + return validatePlanItems(params.Plan) +} + +func validatePlanItems(plan []PlanItem) error { + if len(plan) == 0 { + return errors.New("at least one generation plan item is required") + } + + for _, item := range plan { + if item.Count <= 0 { + return fmt.Errorf("generation count must be positive for bucket %q", item.SourceBucket) + } + if strings.TrimSpace(string(item.SourceBucket)) == "" { + return errors.New("source bucket is required for every generation plan item") + } + } + + return nil +} + +func validateAssignmentOwnership(ctx context.Context, queries *sqlc.Queries, assignmentID, teacherID int64) error { + assignment, err := queries.GetAssignmentByID(ctx, assignmentID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("assignment %d not found", assignmentID) + } + return err + } + if assignment.TeacherID != teacherID { + return fmt.Errorf("assignment %d does not belong to teacher %d", assignmentID, teacherID) + } + return nil +} + +func validateStudentAssignment(ctx context.Context, queries *sqlc.Queries, assignmentID, studentID int64) error { + _, err := queries.GetAssignmentAssignee(ctx, sqlc.GetAssignmentAssigneeParams{ + AssignmentID: assignmentID, + StudentID: studentID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("student %d is not assigned to assignment %d", studentID, assignmentID) + } + return err + } + return nil +} + +func clearStudentQuestionMappings(ctx context.Context, queries *sqlc.Queries, assignmentID, studentID int64) error { + return queries.DeleteAssignmentStudentQuestions(ctx, sqlc.DeleteAssignmentStudentQuestionsParams{ + AssignmentID: assignmentID, + StudentID: studentID, + }) +} + +func normalizeQuestionDefaults(params GenerateStudentQuestionSetParams) (sqlc.QuestionStatus, string) { + questionStatus := params.QuestionStatus + if questionStatus == "" { + questionStatus = sqlc.QuestionStatusDraft + } + + questionSource := strings.TrimSpace(params.QuestionSource) + if questionSource == "" { + questionSource = defaultQuestionSource + } + + return questionStatus, questionSource +} + +func (s *Service) generateAndStorePlan( + ctx context.Context, + queries *sqlc.Queries, + params GenerateStudentQuestionSetParams, + questionStatus sqlc.QuestionStatus, + questionSource string, +) ([]StoredStudentQuestion, error) { + stored := make([]StoredStudentQuestion, 0) + position := int32(1) + + for _, item := range params.Plan { + generatedQuestions, usedSeed, err := s.generator.Generate(questiongen.GenerateParams{ + Topic: item.Topic, + Difficulty: item.Difficulty, + Count: item.Count, + Seed: item.Seed, + }) + if err != nil { + return nil, err + } + + storedBatch, nextPosition, err := storeGeneratedQuestionBatch( + ctx, + queries, + params, + item, + generatedQuestions, + questionStatus, + questionSource, + usedSeed, + position, + ) + if err != nil { + return nil, err + } + + stored = append(stored, storedBatch...) + position = nextPosition + } + + return stored, nil +} + +func storeGeneratedQuestionBatch( + ctx context.Context, + queries *sqlc.Queries, + params GenerateStudentQuestionSetParams, + item PlanItem, + generatedQuestions []questiongen.GeneratedQuestion, + questionStatus sqlc.QuestionStatus, + questionSource string, + usedSeed int64, + startPosition int32, +) ([]StoredStudentQuestion, int32, error) { + stored := make([]StoredStudentQuestion, 0, len(generatedQuestions)) + position := startPosition + + for _, generated := range generatedQuestions { + storedQuestion, err := storeGeneratedQuestion( + ctx, + queries, + params, + item, + generated, + questionStatus, + questionSource, + usedSeed, + position, + ) + if err != nil { + return nil, startPosition, err + } + + stored = append(stored, storedQuestion) + position++ + } + + return stored, position, nil +} + +func storeGeneratedQuestion( + ctx context.Context, + queries *sqlc.Queries, + params GenerateStudentQuestionSetParams, + item PlanItem, + generated questiongen.GeneratedQuestion, + questionStatus sqlc.QuestionStatus, + questionSource string, + usedSeed int64, + position int32, +) (StoredStudentQuestion, error) { + question, err := queries.CreateQuestion(ctx, sqlc.CreateQuestionParams{ + AuthorTeacherID: params.TeacherID, + Title: generated.Title, + Prompt: generated.Prompt, + Topic: nullableQuestionTopic(item.Topic), + Subject: textValue(firstNonEmpty(params.Subject, questionTopicLabel(item.Topic))), + Difficulty: nullableQuestionDifficulty(item.Difficulty), + Source: textValue(questionSource), + Status: questionStatus, + CorrectAnswer: textValue(generated.CorrectAnswer), + }) + if err != nil { + return StoredStudentQuestion{}, err + } + + tags := mergeTags(generated.Tags, string(item.SourceBucket), questionSource) + if err := attachQuestionTags(ctx, queries, question.ID, tags); err != nil { + return StoredStudentQuestion{}, err + } + + mapping, err := queries.AddAssignmentStudentQuestion(ctx, sqlc.AddAssignmentStudentQuestionParams{ + AssignmentID: params.AssignmentID, + StudentID: params.StudentID, + QuestionID: question.ID, + Position: position, + SourceBucket: string(item.SourceBucket), + SourceTopic: nullableQuestionTopic(item.Topic), + SourceDifficulty: nullableQuestionDifficulty(item.Difficulty), + GeneratorSeed: pgtype.Int8{Int64: usedSeed, Valid: true}, + }) + if err != nil { + return StoredStudentQuestion{}, err + } + + return StoredStudentQuestion{ + Mapping: mapping, + Question: question, + Tags: tags, + UsedSeed: usedSeed, + SourceBucket: string(item.SourceBucket), + }, nil +} + +func attachQuestionTags(ctx context.Context, queries *sqlc.Queries, questionID int64, tagNames []string) error { + for _, tagName := range tagNames { + tag, err := queries.CreateTag(ctx, tagName) + if err != nil { + return err + } + if err := queries.AttachTagToQuestion(ctx, sqlc.AttachTagToQuestionParams{ + QuestionID: questionID, + TagID: tag.ID, + }); err != nil { + return err + } + } + + return nil +} diff --git a/Backend/internal/assignmentgen/service_helpers.go b/Backend/internal/assignmentgen/service_helpers.go new file mode 100644 index 0000000..baf9a98 --- /dev/null +++ b/Backend/internal/assignmentgen/service_helpers.go @@ -0,0 +1,89 @@ +package assignmentgen + +import ( + "strings" + + "boostai-backend/internal/sqlc" + + "github.com/jackc/pgx/v5/pgtype" +) + +func nullableQuestionTopic(topic sqlc.QuestionTopic) sqlc.NullQuestionTopic { + if topic == "" { + return sqlc.NullQuestionTopic{} + } + return sqlc.NullQuestionTopic{QuestionTopic: topic, Valid: true} +} + +func nullableQuestionDifficulty(difficulty sqlc.QuestionDifficulty) sqlc.NullQuestionDifficulty { + if difficulty == "" { + return sqlc.NullQuestionDifficulty{} + } + return sqlc.NullQuestionDifficulty{QuestionDifficulty: difficulty, Valid: true} +} + +func textValue(value string) pgtype.Text { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return pgtype.Text{} + } + return pgtype.Text{String: trimmed, Valid: true} +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func mergeTags(base []string, extras ...string) []string { + seen := make(map[string]struct{}, len(base)+len(extras)) + merged := make([]string, 0, len(base)+len(extras)) + + appendTag := func(tag string) { + normalized := strings.TrimSpace(strings.ToLower(tag)) + if normalized == "" { + return + } + if _, exists := seen[normalized]; exists { + return + } + seen[normalized] = struct{}{} + merged = append(merged, normalized) + } + + for _, tag := range base { + appendTag(tag) + } + for _, tag := range extras { + appendTag(tag) + } + + return merged +} + +func questionTopicLabel(topic sqlc.QuestionTopic) string { + switch topic { + case sqlc.QuestionTopicPlaceValue: + return "Place value" + case sqlc.QuestionTopicArithmetic: + return "Arithmetic" + case sqlc.QuestionTopicNegativeNumbers: + return "Negative numbers" + case sqlc.QuestionTopicBidmas: + return "BIDMAS" + case sqlc.QuestionTopicFractions: + return "Fractions" + case sqlc.QuestionTopicAlgebra: + return "Algebra" + case sqlc.QuestionTopicGeometry: + return "Geometry" + case sqlc.QuestionTopicData: + return "Data" + default: + return "Maths" + } +} diff --git a/Backend/internal/config/config.go b/Backend/internal/config/config.go new file mode 100644 index 0000000..37e3f30 --- /dev/null +++ b/Backend/internal/config/config.go @@ -0,0 +1,50 @@ +// Path: Backend/internal/config/config.go + +package config + +import ( + "os" + "strings" +) + +type Config struct { + Port string + Environment string + AllowedOrigins string + DatabaseURL string + JWTSecret string + SessionCookie string + AIReviewEndpoint string + AIReviewAPIKey string + AIReviewModel string +} + +func Load() *Config { + return &Config{ + Port: getEnv("BACKEND_INTERNAL_PORT", "8081"), + Environment: getEnv("GO_ENV", "development"), + AllowedOrigins: getEnv("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:4321,http://localhost:8080,http://windows-wsl:8080"), + DatabaseURL: getEnv("DATABASE_URL", "postgres://boostai:boostai_dev_password@localhost:5439/boostai?sslmode=disable"), + JWTSecret: getEnv("JWT_SECRET", "boostai-dev-jwt-secret-change-me"), + SessionCookie: getEnv("SESSION_COOKIE_NAME", "boostai_session"), + AIReviewEndpoint: getEnv("AI_REVIEW_ENDPOINT", ""), + AIReviewAPIKey: getEnv("AI_REVIEW_API_KEY", ""), + AIReviewModel: getEnv("AI_REVIEW_MODEL", ""), + } +} + +func (c *Config) IsDevelopment() bool { + return strings.ToLower(c.Environment) == "development" +} + +func (c *Config) IsProduction() bool { + return strings.ToLower(c.Environment) == "production" +} + +func getEnv(key, fallback string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + + return fallback +} diff --git a/Backend/internal/database/postgres.go b/Backend/internal/database/postgres.go new file mode 100644 index 0000000..717cf6f --- /dev/null +++ b/Backend/internal/database/postgres.go @@ -0,0 +1,65 @@ +// Path: Backend/internal/database/postgres.go + +package database + +import ( + "context" + "time" + + "boostai-backend/db" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" +) + +type DB struct { + Pool *pgxpool.Pool +} + +func NewPostgres(databaseURL string) (*DB, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + config, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + return nil, err + } + + config.MaxConns = 25 + config.MinConns = 5 + config.MaxConnLifetime = time.Hour + config.MaxConnIdleTime = 30 * time.Minute + + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, err + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, err + } + + return &DB{Pool: pool}, nil +} + +func (d *DB) Migrate() error { + sqlDB := stdlib.OpenDBFromPool(d.Pool) + + goose.SetBaseFS(db.Migrations) + + if err := goose.SetDialect("postgres"); err != nil { + return err + } + + return goose.Up(sqlDB, "migrations") +} + +func (d *DB) Close() { + d.Pool.Close() +} + +func (d *DB) Health(ctx context.Context) error { + return d.Pool.Ping(ctx) +} diff --git a/Backend/internal/handlers/api/answers/handler.go b/Backend/internal/handlers/api/answers/handler.go new file mode 100644 index 0000000..d54c2d0 --- /dev/null +++ b/Backend/internal/handlers/api/answers/handler.go @@ -0,0 +1,562 @@ +// Path: Backend/internal/handlers/api/answers/handler.go + +package answers + +import ( + "boostai-backend/internal/aireview" + "boostai-backend/internal/handlers/api/shared" + "boostai-backend/internal/http/params" + "boostai-backend/internal/http/respond" + authmw "boostai-backend/internal/middleware" + "boostai-backend/internal/sqlc" + "context" + "errors" + "fmt" + "log" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type Handler struct { + queries *sqlc.Queries + aiReview *aireview.Service +} + +type StudentAnswerResponse struct { + ID int64 `json:"id"` + AssignmentID int64 `json:"assignment_id"` + QuestionID int64 `json:"question_id"` + StudentID int64 `json:"student_id"` + AnswerText *string `json:"answer_text,omitempty"` + IsCorrect *bool `json:"is_correct,omitempty"` + SolveMode string `json:"solve_mode"` + WorkingSteps *string `json:"working_steps,omitempty"` + AiFeedback *string `json:"ai_feedback,omitempty"` + TeacherFeedback *string `json:"teacher_feedback,omitempty"` + Status string `json:"status"` + ReviewNeedsAttention bool `json:"review_needs_attention"` + ReviewIssueReason *string `json:"review_issue_reason,omitempty"` + ReviewCorrectnessScore *float64 `json:"review_correctness_score,omitempty"` + ReviewUnderstandingScore *float64 `json:"review_understanding_score,omitempty"` + ReviewQuestionScore *float64 `json:"review_question_score,omitempty"` + ReviewConfidence *float64 `json:"review_confidence,omitempty"` + ReviewTags []string `json:"review_tags"` + SubmittedAt *time.Time `json:"submitted_at,omitempty"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +type upsertStudentAnswerRequest struct { + AssignmentID int64 `json:"assignment_id"` + QuestionID int64 `json:"question_id"` + StudentID int64 `json:"student_id"` + AnswerText *string `json:"answer_text"` + SolveMode string `json:"solve_mode"` + WorkingSteps *string `json:"working_steps"` + AiFeedback *string `json:"ai_feedback"` + TeacherFeedback *string `json:"teacher_feedback"` + Status string `json:"status"` + SubmittedAt *time.Time `json:"submitted_at"` + ReviewedAt *time.Time `json:"reviewed_at"` +} + +type updateAnswerReviewRequest struct { + Status string `json:"status"` + ReviewNeedsAttention *bool `json:"review_needs_attention"` + ReviewIssueReason *string `json:"review_issue_reason"` + ReviewCorrectnessScore *float64 `json:"review_correctness_score"` + ReviewUnderstandingScore *float64 `json:"review_understanding_score"` + ReviewQuestionScore *float64 `json:"review_question_score"` + ReviewConfidence *float64 `json:"review_confidence"` + ReviewTags []string `json:"review_tags"` +} + +func NewHandler(queries *sqlc.Queries, aiReview *aireview.Service) *Handler { + return &Handler{queries: queries, aiReview: aiReview} +} + +func (h *Handler) ListAnswersForAssignment(c *fiber.Ctx) error { + assignmentID, err := params.Int64PathParam(c, "assignmentId") + if err != nil { + return err + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + answers, err := h.queries.ListAnswersForAssignment(ctx, assignmentID) + if err != nil { + return respond.DatabaseError(c, err) + } + + items := make([]StudentAnswerResponse, 0, len(answers)) + for _, answer := range answers { + items = append(items, mapStudentAnswer(answer)) + } + + return c.JSON(shared.ListResponse[StudentAnswerResponse]{Data: items}) +} + +func (h *Handler) ListAnswersForStudent(c *fiber.Ctx) error { + studentID, err := params.Int64PathParam(c, "studentId") + if err != nil { + return err + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + answers, err := h.queries.ListAnswersForStudent(ctx, studentID) + if err != nil { + return respond.DatabaseError(c, err) + } + + items := make([]StudentAnswerResponse, 0, len(answers)) + for _, answer := range answers { + items = append(items, mapStudentAnswer(answer)) + } + + return c.JSON(shared.ListResponse[StudentAnswerResponse]{Data: items}) +} + +func (h *Handler) UpsertStudentAnswer(c *fiber.Ctx) error { + var req upsertStudentAnswerRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + studentID := req.StudentID + if authmw.CurrentUserRole(c) == sqlc.UserRoleStudent { + studentID = authmw.CurrentUserID(c) + } + + if req.AssignmentID == 0 || req.QuestionID == 0 || studentID == 0 || strings.TrimSpace(req.Status) == "" { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "assignment_id, question_id, student identity, and status are required") + } + + solveMode := strings.TrimSpace(req.SolveMode) + if solveMode == "" { + solveMode = "just_answer" + } + + if !isValidSolveMode(solveMode) { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "A valid solve_mode is required") + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + question, err := h.queries.GetQuestionByID(ctx, req.QuestionID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusNotFound, "not_found", "Question not found") + } + return respond.DatabaseError(c, err) + } + + isCorrect := compareAnswer(question.CorrectAnswer, req.AnswerText) + + answer, err := h.queries.UpsertStudentAnswer(ctx, sqlc.UpsertStudentAnswerParams{ + AssignmentID: req.AssignmentID, + QuestionID: req.QuestionID, + StudentID: studentID, + AnswerText: shared.NullableText(req.AnswerText), + SolveMode: solveMode, + WorkingSteps: shared.NullableText(req.WorkingSteps), + AiFeedback: shared.NullableText(req.AiFeedback), + TeacherFeedback: shared.NullableText(req.TeacherFeedback), + Status: sqlc.AnswerStatus(strings.TrimSpace(req.Status)), + SubmittedAt: shared.NullableTime(req.SubmittedAt), + ReviewedAt: shared.NullableTime(req.ReviewedAt), + IsCorrect: shared.NullableBool(isCorrect), + }) + if err != nil { + return respond.DatabaseError(c, err) + } + + if strings.TrimSpace(req.Status) == string(sqlc.AnswerStatusSubmitted) { + updatedAnswer, aiErr := h.runAISubmissionReview(context.Background(), req.AssignmentID, studentID, answer) + if aiErr != nil { + log.Printf("AI review failed for assignment %d student %d: %v", req.AssignmentID, studentID, aiErr) + } else { + answer = updatedAnswer + } + } + + return c.Status(fiber.StatusCreated).JSON(mapStudentAnswer(answer)) +} + +func (h *Handler) runAISubmissionReview(parentCtx context.Context, assignmentID, studentID int64, currentAnswer sqlc.StudentAnswer) (sqlc.StudentAnswer, error) { + if h.aiReview == nil || !h.aiReview.Enabled() { + return currentAnswer, nil + } + + dbCtx, cancel := shared.WithTimeout() + assignment, err := h.queries.GetAssignmentByID(dbCtx, assignmentID) + cancel() + if err != nil { + return currentAnswer, fmt.Errorf("load assignment for AI review: %w", err) + } + + detailCtx, cancel := shared.WithTimeout() + questions, err := h.queries.ListQuestionDetailsForAssignmentStudent(detailCtx, sqlc.ListQuestionDetailsForAssignmentStudentParams{ + AssignmentID: assignmentID, + StudentID: studentID, + }) + cancel() + if err != nil { + return currentAnswer, fmt.Errorf("load assignment question details for AI review: %w", err) + } + + input := buildAssignmentReviewInput(assignment, studentID, questions) + if len(input.Questions) == 0 { + return currentAnswer, nil + } + + var result *aireview.AssignmentReviewResult + var lastErr error + for attempt := 1; attempt <= 3; attempt++ { + attemptCtx, attemptCancel := context.WithTimeout(parentCtx, 45*time.Second) + result, lastErr = h.aiReview.ReviewSubmission(attemptCtx, input) + attemptCancel() + if lastErr == nil { + break + } + if attempt < 3 { + time.Sleep(time.Duration(attempt) * time.Second) + } + } + + if lastErr != nil { + fallbackMessage := fmt.Sprintf("AI review could not be completed automatically after 3 attempts. Please review manually. Last error: %v", lastErr) + updateCtx, updateCancel := shared.WithTimeout() + _, updateErr := h.queries.UpdateAssignmentAIReview(updateCtx, sqlc.UpdateAssignmentAIReviewParams{ + AssignmentID: assignmentID, + StudentID: studentID, + AiFeedback: shared.NullableText(&fallbackMessage), + Column4: "", + }) + updateCancel() + if updateErr != nil { + return currentAnswer, fmt.Errorf("AI review failed (%v) and fallback update failed: %w", lastErr, updateErr) + } + return currentAnswer, lastErr + } + + questionByID := make(map[int64]sqlc.ListQuestionDetailsForAssignmentStudentRow, len(questions)) + for _, question := range questions { + if question.AnswerID.Valid { + questionByID[question.QuestionID] = question + } + } + + updatedAnswer := currentAnswer + for _, review := range result.Questions { + question, ok := questionByID[review.QuestionID] + if !ok || !question.AnswerID.Valid { + continue + } + + aiFeedback := review.AiFeedback + issueReason := review.IssueReason + correctnessScore := 1.0 + questionScore := 1.0 + understandingScore := review.UnderstandingScore + confidence := review.Confidence + + updateCtx, updateCancel := shared.WithTimeout() + answer, updateErr := h.queries.UpdateAnswerAIReview(updateCtx, sqlc.UpdateAnswerAIReviewParams{ + ID: question.AnswerID.Int64, + AiFeedback: shared.NullableText(&aiFeedback), + ReviewNeedsAttention: review.NeedsAttention, + ReviewIssueReason: shared.NullableText(&issueReason), + ReviewCorrectnessScore: mustNumeric(correctnessScore), + ReviewUnderstandingScore: mustNumeric(understandingScore), + ReviewQuestionScore: mustNumeric(questionScore), + ReviewConfidence: mustNumeric(confidence), + }) + updateCancel() + if updateErr != nil { + return currentAnswer, fmt.Errorf("persist AI answer review for answer %d: %w", question.AnswerID.Int64, updateErr) + } + + if answer.ID == currentAnswer.ID { + updatedAnswer = answer + } + } + + for _, question := range questions { + if !question.AnswerID.Valid { + continue + } + + answerText := strings.TrimSpace(shared.TextValue(question.AnswerText)) + workingSteps := strings.TrimSpace(shared.TextValue(question.WorkingSteps)) + if answerText != "" || workingSteps != "" { + continue + } + + updateCtx, updateCancel := shared.WithTimeout() + answer, updateErr := h.queries.UpdateAnswerAIReview(updateCtx, sqlc.UpdateAnswerAIReviewParams{ + ID: question.AnswerID.Int64, + AiFeedback: shared.NullableText(pointerToString("No answer was submitted for this question.")), + ReviewNeedsAttention: true, + ReviewIssueReason: shared.NullableText(pointerToString("No answer submitted.")), + ReviewCorrectnessScore: mustNumeric(1.0), + ReviewUnderstandingScore: mustNumeric(0.0), + ReviewQuestionScore: mustNumeric(1.0), + ReviewConfidence: mustNumeric(1.0), + }) + updateCancel() + if updateErr != nil { + return currentAnswer, fmt.Errorf("persist blank-answer AI review for answer %d: %w", question.AnswerID.Int64, updateErr) + } + + if answer.ID == currentAnswer.ID { + updatedAnswer = answer + } + } + + assignmentSummary := strings.TrimSpace(result.AssignmentSummary) + nextStepOutcome := sqlc.NullAssignmentNextStepOutcome{} + if result.RecommendedNextStep != "" { + nextStepOutcome = sqlc.NullAssignmentNextStepOutcome{ + AssignmentNextStepOutcome: sqlc.AssignmentNextStepOutcome(result.RecommendedNextStep), + Valid: true, + } + } + + updateCtx, updateCancel := shared.WithTimeout() + _, err = h.queries.UpdateAssignmentAIReview(updateCtx, sqlc.UpdateAssignmentAIReviewParams{ + AssignmentID: assignmentID, + StudentID: studentID, + AiFeedback: shared.NullableText(&assignmentSummary), + Column4: nextStepOutcomeString(nextStepOutcome), + }) + updateCancel() + if err != nil { + return currentAnswer, fmt.Errorf("persist assignment AI review: %w", err) + } + + return updatedAnswer, nil +} + +func buildAssignmentReviewInput(assignment sqlc.Assignment, studentID int64, questions []sqlc.ListQuestionDetailsForAssignmentStudentRow) aireview.AssignmentReviewInput { + passThreshold := 6.0 + if value := shared.NumericPointer(assignment.PassThreshold); value != nil { + passThreshold = *value + } + + input := aireview.AssignmentReviewInput{ + AssignmentID: assignment.ID, + StudentID: studentID, + AssignmentTitle: assignment.Title, + Instructions: strings.TrimSpace(shared.TextValue(assignment.Instructions)), + PassThreshold: passThreshold, + Questions: make([]aireview.AssignmentQuestionInput, 0, len(questions)), + } + + for _, question := range questions { + answerText := strings.TrimSpace(shared.TextValue(question.AnswerText)) + workingSteps := strings.TrimSpace(shared.TextValue(question.WorkingSteps)) + answerStatus := "" + if question.AnswerStatus.Valid { + answerStatus = string(question.AnswerStatus.AnswerStatus) + } + + input.Questions = append(input.Questions, aireview.AssignmentQuestionInput{ + QuestionID: question.QuestionID, + Position: question.Position, + Title: question.Title, + Prompt: question.Prompt, + Subject: strings.TrimSpace(shared.TextValue(question.Subject)), + Source: strings.TrimSpace(shared.TextValue(question.Source)), + CorrectAnswer: strings.TrimSpace(shared.TextValue(question.CorrectAnswer)), + QuestionTags: question.QuestionTags, + SolveMode: strings.TrimSpace(shared.TextValue(question.SolveMode)), + AnswerText: answerText, + WorkingSteps: workingSteps, + IsCorrect: shared.BoolPointer(question.IsCorrect), + AnswerStatus: answerStatus, + }) + } + + return input +} + +func mustNumeric(value float64) pgtype.Numeric { + numeric, err := shared.NullableFloat64AsNumeric(&value) + if err != nil { + panic(err) + } + return numeric +} + +func nextStepOutcomeString(value sqlc.NullAssignmentNextStepOutcome) string { + if !value.Valid { + return "" + } + + return string(value.AssignmentNextStepOutcome) +} + +func pointerToString(value string) *string { + return &value +} + +func (h *Handler) UpdateAnswerReview(c *fiber.Ctx) error { + answerID, err := params.Int64PathParam(c, "answerId") + if err != nil { + return err + } + + var req updateAnswerReviewRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + status := strings.TrimSpace(req.Status) + if status == "" || !shared.IsValidAnswerStatus(status) { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "A valid answer status is required") + } + + for _, score := range []struct { + name string + value *float64 + }{ + {name: "review_correctness_score", value: req.ReviewCorrectnessScore}, + {name: "review_understanding_score", value: req.ReviewUnderstandingScore}, + {name: "review_question_score", value: req.ReviewQuestionScore}, + {name: "review_confidence", value: req.ReviewConfidence}, + } { + if score.value != nil && (*score.value < 0 || *score.value > 1) { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", score.name+" must be between 0 and 1") + } + } + + reviewCorrectnessScore, err := shared.NullableFloat64AsNumeric(req.ReviewCorrectnessScore) + if err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "review_correctness_score must be a valid number") + } + + reviewUnderstandingScore, err := shared.NullableFloat64AsNumeric(req.ReviewUnderstandingScore) + if err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "review_understanding_score must be a valid number") + } + + reviewQuestionScore, err := shared.NullableFloat64AsNumeric(req.ReviewQuestionScore) + if err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "review_question_score must be a valid number") + } + + reviewConfidence, err := shared.NullableFloat64AsNumeric(req.ReviewConfidence) + if err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "review_confidence must be a valid number") + } + + reviewNeedsAttention := false + if req.ReviewNeedsAttention != nil { + reviewNeedsAttention = *req.ReviewNeedsAttention + } + + reviewTags := req.ReviewTags + if reviewTags == nil { + reviewTags = []string{} + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + answer, err := h.queries.UpdateAnswerReview(ctx, sqlc.UpdateAnswerReviewParams{ + ID: answerID, + Status: sqlc.AnswerStatus(status), + ReviewNeedsAttention: reviewNeedsAttention, + ReviewIssueReason: shared.NullableText(req.ReviewIssueReason), + ReviewCorrectnessScore: reviewCorrectnessScore, + ReviewUnderstandingScore: reviewUnderstandingScore, + ReviewQuestionScore: reviewQuestionScore, + ReviewConfidence: reviewConfidence, + ReviewTags: reviewTags, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusNotFound, "not_found", "Answer not found") + } + return respond.DatabaseError(c, err) + } + + return c.JSON(mapStudentAnswer(answer)) +} + +func mapStudentAnswer(answer sqlc.StudentAnswer) StudentAnswerResponse { + return StudentAnswerResponse{ + ID: answer.ID, + AssignmentID: answer.AssignmentID, + QuestionID: answer.QuestionID, + StudentID: answer.StudentID, + AnswerText: shared.TextPointer(answer.AnswerText), + IsCorrect: shared.BoolPointer(answer.IsCorrect), + SolveMode: answer.SolveMode, + WorkingSteps: shared.TextPointer(answer.WorkingSteps), + AiFeedback: shared.TextPointer(answer.AiFeedback), + TeacherFeedback: shared.TextPointer(answer.TeacherFeedback), + Status: string(answer.Status), + ReviewNeedsAttention: answer.ReviewNeedsAttention, + ReviewIssueReason: shared.TextPointer(answer.ReviewIssueReason), + ReviewCorrectnessScore: shared.NumericPointer(answer.ReviewCorrectnessScore), + ReviewUnderstandingScore: shared.NumericPointer(answer.ReviewUnderstandingScore), + ReviewQuestionScore: shared.NumericPointer(answer.ReviewQuestionScore), + ReviewConfidence: shared.NumericPointer(answer.ReviewConfidence), + ReviewTags: answer.ReviewTags, + SubmittedAt: shared.TimePointer(answer.SubmittedAt), + ReviewedAt: shared.TimePointer(answer.ReviewedAt), + CreatedAt: shared.TimePointer(answer.CreatedAt), + UpdatedAt: shared.TimePointer(answer.UpdatedAt), + } +} + +func isValidSolveMode(value string) bool { + switch value { + case "just_answer", "step_by_step", "solve_together", "handwritten": + return true + default: + return false + } +} + +func compareAnswer(correctAnswer pgtype.Text, studentAnswer *string) *bool { + if !correctAnswer.Valid { + return nil + } + + canonical := normalizeComparableAnswer(correctAnswer.String) + if canonical == "" { + return nil + } + + if studentAnswer == nil { + return nil + } + + student := normalizeComparableAnswer(*studentAnswer) + if student == "" { + return nil + } + + result := student == canonical + return &result +} + +func normalizeComparableAnswer(value string) string { + trimmed := strings.TrimSpace(strings.ToLower(value)) + if trimmed == "" { + return "" + } + + return strings.Join(strings.Fields(trimmed), " ") +} diff --git a/Backend/internal/handlers/api/answers/routes.go b/Backend/internal/handlers/api/answers/routes.go new file mode 100644 index 0000000..f10d6d7 --- /dev/null +++ b/Backend/internal/handlers/api/answers/routes.go @@ -0,0 +1,16 @@ +// Path: Backend/internal/handlers/api/answers/routes.go + +package answers + +import ( + authmw "boostai-backend/internal/middleware" + + "github.com/gofiber/fiber/v2" +) + +func RegisterRoutes(app fiber.Router, auth *authmw.AuthMiddleware, h *Handler) { + app.Get("/assignments/:assignmentId/answers", auth.RequireTeacher(), h.ListAnswersForAssignment) + app.Get("/students/:studentId/answers", auth.RequireStudentSelfOrTeacher("studentId"), h.ListAnswersForStudent) + app.Post("/answers", h.UpsertStudentAnswer) + app.Patch("/answers/:answerId/review", auth.RequireTeacher(), h.UpdateAnswerReview) +} diff --git a/Backend/internal/handlers/api/assignments/handler.go b/Backend/internal/handlers/api/assignments/handler.go new file mode 100644 index 0000000..a61778e --- /dev/null +++ b/Backend/internal/handlers/api/assignments/handler.go @@ -0,0 +1,660 @@ +package assignments + +import ( + "boostai-backend/internal/aireview" + "boostai-backend/internal/assignmentgen" + "boostai-backend/internal/handlers/api/shared" + "boostai-backend/internal/http/params" + "boostai-backend/internal/http/respond" + authmw "boostai-backend/internal/middleware" + "boostai-backend/internal/sqlc" + "errors" + "fmt" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +type Handler struct { + queries *sqlc.Queries + aiReview *aireview.Service + assignmentGenerator *assignmentgen.Service +} + +const fixedPassThreshold = 6.0 + +func NewHandler(queries *sqlc.Queries, aiReview *aireview.Service, assignmentGenerator *assignmentgen.Service) *Handler { + return &Handler{queries: queries, aiReview: aiReview, assignmentGenerator: assignmentGenerator} +} + +func (h *Handler) ListAssignmentsByTeacher(c *fiber.Ctx) error { + teacherID, err := params.Int64PathParam(c, "teacherId") + if err != nil { + return err + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + assignments, err := h.queries.ListAssignmentsByTeacher(ctx, teacherID) + if err != nil { + return respond.DatabaseError(c, err) + } + + items := make([]AssignmentResponse, 0, len(assignments)) + for _, assignment := range assignments { + items = append(items, mapAssignment(assignment)) + } + + return c.JSON(shared.ListResponse[AssignmentResponse]{Data: items}) +} + +func (h *Handler) ListAssignmentsForStudent(c *fiber.Ctx) error { + studentID, err := params.Int64PathParam(c, "studentId") + if err != nil { + return err + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + assignments, err := h.queries.ListAssignmentsForStudent(ctx, studentID) + if err != nil { + return respond.DatabaseError(c, err) + } + + items := make([]AssignmentResponse, 0, len(assignments)) + for _, assignment := range assignments { + items = append(items, mapAssignment(assignment)) + } + + return c.JSON(shared.ListResponse[AssignmentResponse]{Data: items}) +} + +func (h *Handler) GetAssignmentByID(c *fiber.Ctx) error { + assignmentID, err := params.Int64PathParam(c, "assignmentId") + if err != nil { + return err + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + assignment, err := h.queries.GetAssignmentByID(ctx, assignmentID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusNotFound, "not_found", "Assignment not found") + } + return respond.DatabaseError(c, err) + } + + return c.JSON(mapAssignment(assignment)) +} + +func (h *Handler) ListQuestionsForAssignment(c *fiber.Ctx) error { + assignmentID, err := params.Int64PathParam(c, "assignmentId") + if err != nil { + return err + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + questions, err := h.queries.ListQuestionsForAssignment(ctx, assignmentID) + if err != nil { + return respond.DatabaseError(c, err) + } + + items := make([]AssignmentQuestionResponse, 0, len(questions)) + for _, question := range questions { + items = append(items, mapAssignmentQuestion(question)) + } + + return c.JSON(shared.ListResponse[AssignmentQuestionResponse]{Data: items}) +} + +func (h *Handler) ListQuestionDetailsForAssignmentStudent(c *fiber.Ctx) error { + assignmentID, err := params.Int64PathParam(c, "assignmentId") + if err != nil { + return err + } + + studentID, err := params.Int64PathParam(c, "studentId") + if err != nil { + return err + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + rows, err := h.queries.ListQuestionDetailsForAssignmentStudent(ctx, sqlc.ListQuestionDetailsForAssignmentStudentParams{ + AssignmentID: assignmentID, + StudentID: studentID, + }) + if err != nil { + return respond.DatabaseError(c, err) + } + + items := make([]AssignmentStudentQuestionDetailResponse, 0, len(rows)) + for _, row := range rows { + items = append(items, mapAssignmentStudentQuestionDetail(row, studentID)) + } + + return c.JSON(shared.ListResponse[AssignmentStudentQuestionDetailResponse]{Data: items}) +} + +func (h *Handler) GetAssignmentReviewSummary(c *fiber.Ctx) error { + assignmentID, err := params.Int64PathParam(c, "assignmentId") + if err != nil { + return err + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + summary, err := h.queries.GetAssignmentReviewSummary(ctx, assignmentID) + if err != nil { + return respond.DatabaseError(c, err) + } + + return c.JSON(mapAssignmentReviewSummary(summary)) +} + +func (h *Handler) GetAssignmentRedoPlan(c *fiber.Ctx) error { + assignmentID, err := params.Int64PathParam(c, "assignmentId") + if err != nil { + return err + } + + studentID, err := params.Int64PathParam(c, "studentId") + if err != nil { + return err + } + + ctx, cancel := shared.WithTimeout() + row, err := h.queries.GetAssignmentRedoPlan(ctx, sqlc.GetAssignmentRedoPlanParams{ + AssignmentID: assignmentID, + StudentID: studentID, + }) + cancel() + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusNotFound, "not_found", "Assignment student record not found") + } + return respond.DatabaseError(c, err) + } + + summary, err := h.buildStudentWeaknessSummary(studentID) + if err != nil { + return respond.DatabaseError(c, err) + } + + response := AssignmentRedoPlanResponse{ + AssignmentID: assignmentID, + StudentID: studentID, + RedoPlanGeneratedAt: shared.TimePointer(row.RedoPlanGeneratedAt), + WeaknessSummary: mapWeaknessSummary(studentID, summary), + } + + if row.RedoPlan.Valid { + stored, err := parseStoredRedoPlan(row.RedoPlan.String) + if err != nil { + response.Error = fmt.Sprintf("stored redo plan could not be parsed: %v", err) + } else { + response.TeacherFeedback = emptyStringPointer(stored.TeacherFeedback) + response.Error = stored.Error + response.Plan = stored.Plan + if len(stored.WeaknessSummary.TopicScores) > 0 || len(stored.WeaknessSummary.WeakTags) > 0 || len(stored.WeaknessSummary.RecentIssues) > 0 { + response.WeaknessSummary = mapWeaknessSummary(studentID, stored.WeaknessSummary) + } + } + } + + return c.JSON(response) +} + +func (h *Handler) UpdateAssignmentDraft(c *fiber.Ctx) error { + assignmentID, err := params.Int64PathParam(c, "assignmentId") + if err != nil { + return err + } + + var req updateAssignmentDraftRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + teacherID := authmw.CurrentUserID(c) + title := strings.TrimSpace(req.Title) + if req.ClassroomID == 0 || teacherID == 0 || title == "" { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "classroom_id, teacher authentication, and title are required") + } + + passThreshold, err := shared.NullableFloat64AsNumeric(pointerToFloat64(fixedPassThreshold)) + if err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "pass_threshold must be a valid number") + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + assignment, err := h.queries.GetAssignmentByID(ctx, assignmentID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusNotFound, "not_found", "Assignment not found") + } + return respond.DatabaseError(c, err) + } + + if assignment.TeacherID != teacherID { + return respond.Error(c, fiber.StatusForbidden, "forbidden", "You can only edit your own draft assignments") + } + + if assignment.Status != sqlc.AssignmentStatusDraft { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Only draft assignments can be edited here") + } + + classrooms, err := h.queries.ListClassroomsByTeacher(ctx, teacherID) + if err != nil { + return respond.DatabaseError(c, err) + } + + classroomAllowed := false + for _, classroom := range classrooms { + if classroom.ID == req.ClassroomID { + classroomAllowed = true + break + } + } + + if !classroomAllowed { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Choose one of your classrooms for this draft") + } + + updatedAssignment, err := h.queries.UpdateAssignmentDraft(ctx, sqlc.UpdateAssignmentDraftParams{ + ID: assignmentID, + ClassroomID: req.ClassroomID, + Title: title, + Instructions: shared.NullableText(req.Instructions), + PassThreshold: passThreshold, + DueAt: shared.NullableTime(req.DueAt), + }) + if err != nil { + return respond.DatabaseError(c, err) + } + + return c.JSON(mapAssignment(updatedAssignment)) +} + +func (h *Handler) UpdateAssignmentTeacherFeedback(c *fiber.Ctx) error { + assignmentID, err := params.Int64PathParam(c, "assignmentId") + if err != nil { + return err + } + + studentID, err := params.Int64PathParam(c, "studentId") + if err != nil { + return err + } + + var req updateAssignmentTeacherFeedbackRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + passStatusOverrideValue := "" + if req.PassStatusOverride != nil { + passStatusOverrideValue = strings.TrimSpace(*req.PassStatusOverride) + if passStatusOverrideValue != "" && !isValidAssignmentPassStatus(passStatusOverrideValue) { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "pass_status_override must be pending, pass, no_pass, or empty") + } + } + + nextStepOutcomeValue := "" + if req.NextStepOutcome != nil { + nextStepOutcomeValue = strings.TrimSpace(*req.NextStepOutcome) + if nextStepOutcomeValue != "" && !isValidAssignmentNextStepOutcome(nextStepOutcomeValue) { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "next_step_outcome must be redo, accept, support, or empty") + } + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + row, err := h.queries.UpdateAssignmentTeacherFeedback(ctx, sqlc.UpdateAssignmentTeacherFeedbackParams{ + AssignmentID: assignmentID, + StudentID: studentID, + TeacherFeedback: shared.NullableText(req.TeacherFeedback), + Column4: passStatusOverrideValue, + Column5: nextStepOutcomeValue, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusNotFound, "not_found", "Assignment student record not found") + } + return respond.DatabaseError(c, err) + } + + if nextStepOutcomeValue == string(sqlc.AssignmentNextStepOutcomeRedo) { + if err := h.generateAndStoreRedoPlan(assignmentID, studentID, strings.TrimSpace(shared.TextValue(row.TeacherFeedback))); err != nil { + fmt.Printf("redo plan generation failed for assignment %d student %d: %v\n", assignmentID, studentID, err) + } + } else { + clearCtx, clearCancel := shared.WithTimeout() + _, clearErr := h.queries.UpdateAssignmentRedoPlan(clearCtx, sqlc.UpdateAssignmentRedoPlanParams{ + AssignmentID: assignmentID, + StudentID: studentID, + Column3: "", + }) + clearCancel() + if clearErr != nil && !errors.Is(clearErr, pgx.ErrNoRows) { + fmt.Printf("redo plan clear failed for assignment %d student %d: %v\n", assignmentID, studentID, clearErr) + } + } + + var passStatusOverride *string + if row.PassStatusOverride.Valid { + status := string(row.PassStatusOverride.AssignmentPassStatus) + passStatusOverride = &status + } + + var nextStepOutcome *string + if row.NextStepOutcome.Valid { + outcome := string(row.NextStepOutcome.AssignmentNextStepOutcome) + nextStepOutcome = &outcome + } + + return c.JSON(fiber.Map{ + "assignment_id": assignmentID, + "student_id": studentID, + "ai_feedback": shared.TextPointer(row.AiFeedback), + "teacher_feedback": shared.TextPointer(row.TeacherFeedback), + "overall_score": shared.NumericPointer(row.OverallScore), + "pass_threshold": shared.NumericPointer(row.PassThreshold), + "next_step_outcome": nextStepOutcome, + "pass_status_override": passStatusOverride, + "pass_status": string(row.PassStatus), + }) +} + +func (h *Handler) CloseAssignment(c *fiber.Ctx) error { + assignmentID, err := params.Int64PathParam(c, "assignmentId") + if err != nil { + return err + } + + teacherID := authmw.CurrentUserID(c) + if teacherID == 0 { + return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "Teacher authentication is required") + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + assignment, err := h.queries.GetAssignmentByID(ctx, assignmentID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusNotFound, "not_found", "Assignment not found") + } + return respond.DatabaseError(c, err) + } + + if assignment.TeacherID != teacherID { + return respond.Error(c, fiber.StatusForbidden, "forbidden", "You can only close your own assignments") + } + + if assignment.Status == sqlc.AssignmentStatusDraft { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Draft assignments cannot be closed") + } + + if assignment.Status == sqlc.AssignmentStatusClosed { + return c.JSON(mapAssignment(assignment)) + } + + queue, err := h.queries.ListAssignmentReviewQueue(ctx, sqlc.ListAssignmentReviewQueueParams{ + AssignmentID: assignmentID, + Column2: "", + }) + if err != nil { + return respond.DatabaseError(c, err) + } + + readiness := buildAssignmentCloseReadiness(queue) + if !readiness.CanClose { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "assignment_not_ready_to_close", + "message": "This assignment still has open review blockers.", + "blockers": readiness.Blockers, + }) + } + + closedAssignment, err := h.queries.CloseAssignment(ctx, assignmentID) + if err != nil { + return respond.DatabaseError(c, err) + } + + return c.JSON(mapAssignment(closedAssignment)) +} + +func (h *Handler) ListAssignmentReviewQueue(c *fiber.Ctx) error { + assignmentID, err := params.Int64PathParam(c, "assignmentId") + if err != nil { + return err + } + + statusFilter := strings.TrimSpace(c.Query("status")) + if statusFilter != "" && !shared.IsValidAnswerStatus(statusFilter) { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Invalid review status filter") + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + rows, err := h.queries.ListAssignmentReviewQueue(ctx, sqlc.ListAssignmentReviewQueueParams{ + AssignmentID: assignmentID, + Column2: statusFilter, + }) + if err != nil { + return respond.DatabaseError(c, err) + } + + items := make([]AssignmentReviewQueueItemResponse, 0, len(rows)) + for _, row := range rows { + items = append(items, mapAssignmentReviewQueueItem(row)) + } + + return c.JSON(shared.ListResponse[AssignmentReviewQueueItemResponse]{Data: items}) +} + +func (h *Handler) CreateAssignment(c *fiber.Ctx) error { + var req createAssignmentRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + teacherID := authmw.CurrentUserID(c) + if req.ClassroomID == 0 || teacherID == 0 || strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.Status) == "" { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "classroom_id, teacher authentication, title, and status are required") + } + + passThreshold, err := shared.NullableFloat64AsNumeric(pointerToFloat64(fixedPassThreshold)) + if err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "pass_threshold must be a valid number") + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + assignment, err := h.queries.CreateAssignment(ctx, sqlc.CreateAssignmentParams{ + ClassroomID: req.ClassroomID, + TeacherID: teacherID, + Title: strings.TrimSpace(req.Title), + Instructions: shared.NullableText(req.Instructions), + PassThreshold: passThreshold, + Status: sqlc.AssignmentStatus(strings.TrimSpace(req.Status)), + DueAt: shared.NullableTime(req.DueAt), + PublishedAt: shared.NullableTime(req.PublishedAt), + }) + if err != nil { + return respond.DatabaseError(c, err) + } + + return c.Status(fiber.StatusCreated).JSON(mapAssignment(assignment)) +} + +func (h *Handler) AssignStudentToAssignment(c *fiber.Ctx) error { + assignmentID, err := params.Int64PathParam(c, "assignmentId") + if err != nil { + return err + } + + var req assignStudentToAssignmentRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + if req.StudentID == 0 { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "student_id is required") + } + + teacherID := authmw.CurrentUserID(c) + if teacherID == 0 { + return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "Teacher authentication is required") + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + assignment, err := h.queries.GetAssignmentByID(ctx, assignmentID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusNotFound, "not_found", "Assignment not found") + } + return respond.DatabaseError(c, err) + } + + if assignment.TeacherID != teacherID { + return respond.Error(c, fiber.StatusForbidden, "forbidden", "You can only assign students to your own assignments") + } + + err = h.queries.AssignStudentToAssignment(ctx, sqlc.AssignStudentToAssignmentParams{ + AssignmentID: assignmentID, + StudentID: req.StudentID, + }) + if err != nil { + return respond.DatabaseError(c, err) + } + + response := fiber.Map{ + "status": "ok", + "assignment_id": assignmentID, + "student_id": req.StudentID, + } + + if req.MixedGeneration != nil { + if h.assignmentGenerator == nil { + cleanupErr := h.queries.DeleteAssignmentAssignee(ctx, sqlc.DeleteAssignmentAssigneeParams{ + AssignmentID: assignmentID, + StudentID: req.StudentID, + }) + if cleanupErr != nil { + return respond.DatabaseError(c, cleanupErr) + } + return respond.Error(c, fiber.StatusServiceUnavailable, "generator_unavailable", "Assignment generator is not configured") + } + + generated, generationErr := h.generateMixedStudentQuestionsForAssignmentStudent(ctx, assignmentID, req.StudentID, teacherID, req.MixedGeneration) + if generationErr != nil { + cleanupErr := h.queries.DeleteAssignmentAssignee(ctx, sqlc.DeleteAssignmentAssigneeParams{ + AssignmentID: assignmentID, + StudentID: req.StudentID, + }) + if cleanupErr != nil { + return respond.DatabaseError(c, cleanupErr) + } + if apiErr, ok := generationErr.(*assignmentAPIError); ok { + return respond.Error(c, apiErr.status, apiErr.code, apiErr.message) + } + return respond.DatabaseError(c, generationErr) + } + + response["mixed_generation"] = generated + } + + return c.Status(fiber.StatusCreated).JSON(response) +} + +func (h *Handler) AddQuestionToAssignment(c *fiber.Ctx) error { + assignmentID, err := params.Int64PathParam(c, "assignmentId") + if err != nil { + return err + } + + var req addQuestionToAssignmentRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + if req.QuestionID == 0 || req.Position <= 0 { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "question_id and positive position are required") + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + err = h.queries.AddQuestionToAssignment(ctx, sqlc.AddQuestionToAssignmentParams{ + AssignmentID: assignmentID, + QuestionID: req.QuestionID, + Position: req.Position, + }) + if err != nil { + return respond.DatabaseError(c, err) + } + + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "status": "ok", + "assignment_id": assignmentID, + "question_id": req.QuestionID, + "position": req.Position, + }) +} + +func (h *Handler) GenerateMixedStudentQuestions(c *fiber.Ctx) error { + assignmentID, err := params.Int64PathParam(c, "assignmentId") + if err != nil { + return err + } + + studentID, err := params.Int64PathParam(c, "studentId") + if err != nil { + return err + } + + var req generateMixedStudentQuestionsRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + teacherID := authmw.CurrentUserID(c) + if teacherID == 0 { + return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "Teacher authentication is required") + } + + if h.assignmentGenerator == nil { + return respond.Error(c, fiber.StatusServiceUnavailable, "generator_unavailable", "Assignment generator is not configured") + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + response, generationErr := h.generateMixedStudentQuestionsForAssignmentStudent(ctx, assignmentID, studentID, teacherID, &req) + if generationErr != nil { + if apiErr, ok := generationErr.(*assignmentAPIError); ok { + return respond.Error(c, apiErr.status, apiErr.code, apiErr.message) + } + return respond.DatabaseError(c, generationErr) + } + + return c.Status(fiber.StatusCreated).JSON(response) +} diff --git a/Backend/internal/handlers/api/assignments/handler_generation.go b/Backend/internal/handlers/api/assignments/handler_generation.go new file mode 100644 index 0000000..0cc7d7a --- /dev/null +++ b/Backend/internal/handlers/api/assignments/handler_generation.go @@ -0,0 +1,321 @@ +package assignments + +import ( + "boostai-backend/internal/aireview" + "boostai-backend/internal/assignmentgen" + "boostai-backend/internal/handlers/api/shared" + "boostai-backend/internal/sqlc" + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +func (h *Handler) generateMixedStudentQuestionsForAssignmentStudent( + ctx context.Context, + assignmentID int64, + studentID int64, + teacherID int64, + req *generateMixedStudentQuestionsRequest, +) (generateMixedStudentQuestionsResponse, error) { + if h.assignmentGenerator == nil { + return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusServiceUnavailable, code: "generator_unavailable", message: "Assignment generator is not configured"} + } + + primaryTopic, err := parseQuestionTopicValue(req.PrimaryTopic) + if err != nil { + return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusBadRequest, code: "invalid_request", message: err.Error()} + } + + primaryDifficulty, err := parseQuestionDifficultyValue(req.PrimaryDifficulty) + if err != nil { + return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusBadRequest, code: "invalid_request", message: err.Error()} + } + + if req.TotalQuestions <= 0 { + return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusBadRequest, code: "invalid_request", message: "total_questions must be greater than 0"} + } + + personalizedDifficulty := primaryDifficulty + if req.PersonalizedDifficulty != nil && strings.TrimSpace(*req.PersonalizedDifficulty) != "" { + personalizedDifficulty, err = parseQuestionDifficultyValue(*req.PersonalizedDifficulty) + if err != nil { + return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusBadRequest, code: "invalid_request", message: err.Error()} + } + } + + questionStatus := sqlc.QuestionStatusDraft + if req.QuestionStatus != nil && strings.TrimSpace(*req.QuestionStatus) != "" { + questionStatus, err = parseQuestionStatusValue(*req.QuestionStatus) + if err != nil { + return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusBadRequest, code: "invalid_request", message: err.Error()} + } + } + + personalizedRatio := 0.0 + if req.PersonalizedRatio != nil { + personalizedRatio = *req.PersonalizedRatio + } + + assignment, err := h.queries.GetAssignmentByID(ctx, assignmentID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusNotFound, code: "not_found", message: "Assignment not found"} + } + return generateMixedStudentQuestionsResponse{}, err + } + + if assignment.TeacherID != teacherID { + return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusForbidden, code: "forbidden", message: "You can only generate questions for your own assignments"} + } + + _, err = h.queries.GetAssignmentAssignee(ctx, sqlc.GetAssignmentAssigneeParams{ + AssignmentID: assignmentID, + StudentID: studentID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusNotFound, code: "not_found", message: "Student is not assigned to this assignment"} + } + return generateMixedStudentQuestionsResponse{}, err + } + + result, err := h.assignmentGenerator.GenerateAndStoreMixedStudentQuestions(ctx, assignmentgen.GenerateMixedStudentQuestionSetParams{ + AssignmentID: assignmentID, + StudentID: studentID, + TeacherID: teacherID, + Subject: trimmedPointerValue(req.Subject), + QuestionStatus: questionStatus, + QuestionSource: trimmedPointerValue(req.QuestionSource), + PrimaryTopic: primaryTopic, + PrimaryDifficulty: primaryDifficulty, + TotalQuestions: req.TotalQuestions, + PersonalizedRatio: personalizedRatio, + Seed: int64Value(req.Seed), + PersonalizedDifficulty: personalizedDifficulty, + }) + if err != nil { + return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusBadRequest, code: "generation_failed", message: err.Error()} + } + + questions := make([]mixedPlanQuestionResponse, 0, len(result.StoredQuestions)) + for _, item := range result.StoredQuestions { + questions = append(questions, mixedPlanQuestionResponse{ + MappingID: item.Mapping.ID, + QuestionID: item.Question.ID, + Position: item.Mapping.Position, + SourceBucket: item.Mapping.SourceBucket, + SourceTopic: questionTopicPointer(item.Mapping.SourceTopic), + SourceDifficulty: questionDifficultyPointer(item.Mapping.SourceDifficulty), + GeneratorSeed: int64Pointer(item.UsedSeed), + Title: item.Question.Title, + Prompt: item.Question.Prompt, + Subject: shared.TextPointer(item.Question.Subject), + QuestionStatus: string(item.Question.Status), + QuestionSource: shared.TextPointer(item.Question.Source), + CorrectAnswer: shared.TextPointer(item.Question.CorrectAnswer), + Tags: item.Tags, + QuestionCreatedAt: shared.TimePointer(item.Question.CreatedAt), + QuestionUpdatedAt: shared.TimePointer(item.Question.UpdatedAt), + }) + } + + response := generateMixedStudentQuestionsResponse{ + AssignmentID: assignmentID, + StudentID: studentID, + PrimaryTopic: string(primaryTopic), + PrimaryDifficulty: string(primaryDifficulty), + TotalQuestions: req.TotalQuestions, + CoreCount: result.MixedPlan.CoreCount, + PersonalizedCount: result.MixedPlan.PersonalizedCount, + PersonalizedApplied: result.MixedPlan.PersonalizedApplied, + PersonalizedRatio: personalizedRatioValue(req.PersonalizedRatio), + BaseSeed: result.MixedPlan.BaseSeed, + WeaknessSummary: mapAssignmentGenerationWeaknessSummary(result.MixedPlan.WeaknessSummary), + Questions: questions, + } + if result.MixedPlan.PersonalizedApplied { + response.PersonalizedTopic = stringPointer(string(result.MixedPlan.PersonalizedTopic)) + } + + return response, nil +} + +func (h *Handler) generateAndStoreRedoPlan(assignmentID, studentID int64, teacherFeedback string) error { + summary, err := h.buildStudentWeaknessSummary(studentID) + if err != nil { + return fmt.Errorf("build weakness summary: %w", err) + } + + stored := storedRedoPlan{ + TeacherFeedback: teacherFeedback, + WeaknessSummary: summary, + } + + if h.aiReview != nil && h.aiReview.Enabled() { + assignmentCtx, assignmentCancel := shared.WithTimeout() + assignment, err := h.queries.GetAssignmentByID(assignmentCtx, assignmentID) + assignmentCancel() + if err != nil { + return fmt.Errorf("load assignment for redo plan: %w", err) + } + + passThreshold := fixedPassThreshold + if value := shared.NumericPointer(assignment.PassThreshold); value != nil { + passThreshold = *value + } + + planCtx, planCancel := context.WithTimeout(context.Background(), 45*time.Second) + defer planCancel() + + plan, planErr := h.aiReview.PlanRedoAssignment(planCtx, aireview.RedoPlanInput{ + AssignmentID: assignmentID, + StudentID: studentID, + AssignmentTitle: assignment.Title, + Instructions: strings.TrimSpace(shared.TextValue(assignment.Instructions)), + TeacherFeedback: teacherFeedback, + PassThreshold: passThreshold, + TopicScores: summary.TopicScores, + WeakTags: summary.WeakTags, + RecentIssues: summary.RecentIssues, + AllowedTopics: allowedQuestionTopics(), + AllowedDifficulties: []string{"easy", "medium", "hard"}, + }) + if planErr != nil { + stored.Error = fmt.Sprintf("AI redo plan could not be generated automatically: %v", planErr) + } else { + stored.Plan = plan + } + } + + payload, err := json.Marshal(stored) + if err != nil { + return fmt.Errorf("marshal redo plan payload: %w", err) + } + + updateCtx, updateCancel := shared.WithTimeout() + defer updateCancel() + _, err = h.queries.UpdateAssignmentRedoPlan(updateCtx, sqlc.UpdateAssignmentRedoPlanParams{ + AssignmentID: assignmentID, + StudentID: studentID, + Column3: string(payload), + }) + if err != nil { + return fmt.Errorf("persist redo plan: %w", err) + } + + return nil +} + +func (h *Handler) buildStudentWeaknessSummary(studentID int64) (weaknessSummary, error) { + ctx, cancel := shared.WithTimeout() + rows, err := h.queries.ListStudentPlanningPerformance(ctx, studentID) + cancel() + if err != nil { + return weaknessSummary{}, err + } + + topicTotals := map[string]struct { + sum float64 + count int + }{} + tagTotals := map[string]struct { + sum float64 + count int + flagged int + }{} + recentIssues := make([]string, 0, 5) + seenIssues := map[string]struct{}{} + + for _, row := range rows { + score := planningScore(row.IsCorrect, row.ReviewUnderstandingScore) + if row.Topic.Valid { + key := string(row.Topic.QuestionTopic) + total := topicTotals[key] + total.sum += score + total.count++ + topicTotals[key] = total + } + + for _, tag := range row.QuestionTags { + tag = strings.TrimSpace(tag) + if tag == "" { + continue + } + total := tagTotals[tag] + total.sum += score + total.count++ + if row.ReviewNeedsAttention { + total.flagged++ + } + tagTotals[tag] = total + } + + issue := strings.TrimSpace(shared.TextValue(row.ReviewIssueReason)) + if issue != "" { + if _, exists := seenIssues[issue]; !exists { + seenIssues[issue] = struct{}{} + recentIssues = append(recentIssues, issue) + if len(recentIssues) >= 5 { + // keep collecting scores, but no need for more issue strings + } + } + } + } + + topicScores := make(map[string]float64, len(topicTotals)) + for topic, total := range topicTotals { + if total.count == 0 { + continue + } + topicScores[topic] = roundToOneDecimal((total.sum / float64(total.count)) * 100) + } + + type weakTagCandidate struct { + tag string + score float64 + flagged int + } + candidates := make([]weakTagCandidate, 0, len(tagTotals)) + for tag, total := range tagTotals { + if total.count == 0 { + continue + } + avg := (total.sum / float64(total.count)) * 100 + if avg < 70 || total.flagged > 0 { + candidates = append(candidates, weakTagCandidate{tag: tag, score: avg, flagged: total.flagged}) + } + } + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].score == candidates[j].score { + if candidates[i].flagged == candidates[j].flagged { + return candidates[i].tag < candidates[j].tag + } + return candidates[i].flagged > candidates[j].flagged + } + return candidates[i].score < candidates[j].score + }) + weakTags := make([]string, 0, minInt(len(candidates), 6)) + for _, candidate := range candidates { + weakTags = append(weakTags, candidate.tag) + if len(weakTags) >= 6 { + break + } + } + + if len(recentIssues) > 5 { + recentIssues = recentIssues[:5] + } + + return weaknessSummary{ + TopicScores: topicScores, + WeakTags: weakTags, + RecentIssues: recentIssues, + }, nil +} diff --git a/Backend/internal/handlers/api/assignments/handler_helpers.go b/Backend/internal/handlers/api/assignments/handler_helpers.go new file mode 100644 index 0000000..0364c9f --- /dev/null +++ b/Backend/internal/handlers/api/assignments/handler_helpers.go @@ -0,0 +1,375 @@ +package assignments + +import ( + "boostai-backend/internal/assignmentgen" + "boostai-backend/internal/handlers/api/shared" + "boostai-backend/internal/sqlc" + "encoding/json" + "fmt" + "math" + "strings" + + "github.com/jackc/pgx/v5/pgtype" +) + +func mapAssignment(assignment sqlc.Assignment) AssignmentResponse { + return AssignmentResponse{ + ID: assignment.ID, + ClassroomID: assignment.ClassroomID, + TeacherID: assignment.TeacherID, + Title: assignment.Title, + Instructions: shared.TextPointer(assignment.Instructions), + PassThreshold: shared.NumericPointer(assignment.PassThreshold), + Status: string(assignment.Status), + DueAt: shared.TimePointer(assignment.DueAt), + PublishedAt: shared.TimePointer(assignment.PublishedAt), + CreatedAt: shared.TimePointer(assignment.CreatedAt), + UpdatedAt: shared.TimePointer(assignment.UpdatedAt), + } +} + +func parseQuestionTopicValue(value string) (sqlc.QuestionTopic, error) { + switch strings.TrimSpace(strings.ToLower(value)) { + case string(sqlc.QuestionTopicPlaceValue): + return sqlc.QuestionTopicPlaceValue, nil + case string(sqlc.QuestionTopicArithmetic): + return sqlc.QuestionTopicArithmetic, nil + case string(sqlc.QuestionTopicNegativeNumbers): + return sqlc.QuestionTopicNegativeNumbers, nil + case string(sqlc.QuestionTopicBidmas): + return sqlc.QuestionTopicBidmas, nil + case string(sqlc.QuestionTopicFractions): + return sqlc.QuestionTopicFractions, nil + case string(sqlc.QuestionTopicAlgebra): + return sqlc.QuestionTopicAlgebra, nil + case string(sqlc.QuestionTopicGeometry): + return sqlc.QuestionTopicGeometry, nil + case string(sqlc.QuestionTopicData): + return sqlc.QuestionTopicData, nil + default: + return "", fmt.Errorf("primary_topic must be one of place_value, arithmetic, negative_numbers, bidmas, fractions, algebra, geometry, or data") + } +} + +func parseQuestionDifficultyValue(value string) (sqlc.QuestionDifficulty, error) { + switch strings.TrimSpace(strings.ToLower(value)) { + case string(sqlc.QuestionDifficultyEasy): + return sqlc.QuestionDifficultyEasy, nil + case string(sqlc.QuestionDifficultyMedium): + return sqlc.QuestionDifficultyMedium, nil + case string(sqlc.QuestionDifficultyHard): + return sqlc.QuestionDifficultyHard, nil + default: + return "", fmt.Errorf("difficulty must be one of easy, medium, or hard") + } +} + +func parseQuestionStatusValue(value string) (sqlc.QuestionStatus, error) { + switch strings.TrimSpace(strings.ToLower(value)) { + case string(sqlc.QuestionStatusDraft): + return sqlc.QuestionStatusDraft, nil + case string(sqlc.QuestionStatusPublished): + return sqlc.QuestionStatusPublished, nil + case string(sqlc.QuestionStatusArchived): + return sqlc.QuestionStatusArchived, nil + default: + return "", fmt.Errorf("question_status must be one of draft, published, or archived") + } +} + +func mapAssignmentGenerationWeaknessSummary(summary assignmentgen.WeaknessSummary) mixedPlanWeaknessSummaryResponse { + topicScores := make(map[string]float64, len(summary.TopicScores)) + for topic, score := range summary.TopicScores { + topicScores[string(topic)] = score + } + + return mixedPlanWeaknessSummaryResponse{ + TopicScores: topicScores, + WeakTags: append([]string(nil), summary.WeakTags...), + RecentIssues: append([]string(nil), summary.RecentIssues...), + } +} + +func questionTopicPointer(topic sqlc.NullQuestionTopic) *string { + if !topic.Valid { + return nil + } + value := string(topic.QuestionTopic) + return &value +} + +func questionDifficultyPointer(difficulty sqlc.NullQuestionDifficulty) *string { + if !difficulty.Valid { + return nil + } + value := string(difficulty.QuestionDifficulty) + return &value +} + +func personalizedRatioValue(value *float64) float64 { + if value == nil || *value == 0 { + return 0.30 + } + return *value +} + +func int64Value(value *int64) int64 { + if value == nil { + return 0 + } + return *value +} + +func int64Pointer(value int64) *int64 { + return &value +} + +func stringPointer(value string) *string { + return &value +} + +func trimmedPointerValue(value *string) string { + if value == nil { + return "" + } + return strings.TrimSpace(*value) +} + +func mapAssignmentQuestion(question sqlc.ListQuestionsForAssignmentRow) AssignmentQuestionResponse { + return AssignmentQuestionResponse{ + AssignmentID: question.AssignmentID, + QuestionID: question.QuestionID, + Position: question.Position, + AuthorTeacherID: question.AuthorTeacherID, + Title: question.Title, + Prompt: question.Prompt, + Subject: shared.TextPointer(question.Subject), + Source: shared.TextPointer(question.Source), + QuestionStatus: string(question.Status), + QuestionCreatedAt: shared.TimePointer(question.CreatedAt), + QuestionUpdatedAt: shared.TimePointer(question.UpdatedAt), + } +} + +func mapAssignmentStudentQuestionDetail(row sqlc.ListQuestionDetailsForAssignmentStudentRow, studentID int64) AssignmentStudentQuestionDetailResponse { + var answerStatus *string + if row.AnswerStatus.Valid { + status := string(row.AnswerStatus.AnswerStatus) + answerStatus = &status + } + + var passStatus *string + if row.PassStatus.Valid { + status := string(row.PassStatus.AssignmentPassStatus) + passStatus = &status + } + + var passStatusOverride *string + if row.PassStatusOverride.Valid { + status := string(row.PassStatusOverride.AssignmentPassStatus) + passStatusOverride = &status + } + + var nextStepOutcome *string + if row.NextStepOutcome.Valid { + outcome := string(row.NextStepOutcome.AssignmentNextStepOutcome) + nextStepOutcome = &outcome + } + + var reviewNeedsAttention *bool + if row.AnswerID.Valid { + reviewNeedsAttention = shared.BoolPointer(row.ReviewNeedsAttention) + } + + return AssignmentStudentQuestionDetailResponse{ + AssignmentID: row.AssignmentID, + StudentID: studentID, + QuestionID: row.QuestionID, + Position: row.Position, + Title: row.Title, + Prompt: row.Prompt, + Subject: shared.TextPointer(row.Subject), + Source: shared.TextPointer(row.Source), + QuestionTags: row.QuestionTags, + QuestionStatus: string(row.QuestionStatus), + CorrectAnswer: shared.TextPointer(row.CorrectAnswer), + AssignmentAiFeedback: shared.TextPointer(row.AssignmentAiFeedback), + AssignmentTeacherFeedback: shared.TextPointer(row.AssignmentTeacherFeedback), + OverallScore: shared.NumericPointer(row.OverallScore), + PassThreshold: shared.NumericPointer(row.PassThreshold), + NextStepOutcome: nextStepOutcome, + PassStatusOverride: passStatusOverride, + PassStatus: passStatus, + AnswerID: shared.Int64Pointer(row.AnswerID), + AnswerText: shared.TextPointer(row.AnswerText), + SolveMode: shared.TextPointer(row.SolveMode), + WorkingSteps: shared.TextPointer(row.WorkingSteps), + IsCorrect: shared.BoolPointer(row.IsCorrect), + AiFeedback: shared.TextPointer(row.AiFeedback), + TeacherFeedback: shared.TextPointer(row.TeacherFeedback), + AnswerStatus: answerStatus, + ReviewNeedsAttention: reviewNeedsAttention, + ReviewIssueReason: shared.TextPointer(row.ReviewIssueReason), + ReviewCorrectnessScore: shared.NumericPointer(row.ReviewCorrectnessScore), + ReviewUnderstandingScore: shared.NumericPointer(row.ReviewUnderstandingScore), + ReviewQuestionScore: shared.NumericPointer(row.ReviewQuestionScore), + ReviewConfidence: shared.NumericPointer(row.ReviewConfidence), + ReviewTags: row.ReviewTags, + SubmittedAt: shared.TimePointer(row.SubmittedAt), + ReviewedAt: shared.TimePointer(row.ReviewedAt), + AnswerCreatedAt: shared.TimePointer(row.AnswerCreatedAt), + AnswerUpdatedAt: shared.TimePointer(row.AnswerUpdatedAt), + } +} + +func mapAssignmentReviewSummary(summary sqlc.GetAssignmentReviewSummaryRow) AssignmentReviewSummaryResponse { + return AssignmentReviewSummaryResponse{ + AssignmentID: summary.AssignmentID, + TotalQuestions: summary.TotalQuestions, + TotalAssigned: summary.TotalAssigned, + NotStarted: summary.NotStarted, + InProgress: summary.InProgress, + Submitted: summary.Submitted, + Reviewed: summary.Reviewed, + } +} + +func mapAssignmentReviewQueueItem(row sqlc.ListAssignmentReviewQueueRow) AssignmentReviewQueueItemResponse { + var nextStepOutcome *string + if row.NextStepOutcome.Valid { + outcome := string(row.NextStepOutcome.AssignmentNextStepOutcome) + nextStepOutcome = &outcome + } + + return AssignmentReviewQueueItemResponse{ + AssignmentID: row.AssignmentID, + StudentID: row.StudentID, + NextStepOutcome: nextStepOutcome, + StudentName: row.StudentName, + StudentEmail: row.StudentEmail, + TotalQuestions: row.TotalQuestions, + AnsweredQuestions: row.AnsweredQuestions, + ReviewedQuestions: row.ReviewedQuestions, + SubmittedQuestions: row.SubmittedQuestions, + InProgressQuestions: row.InProgressQuestions, + ReviewStatus: string(row.ReviewStatus), + LatestSubmittedAt: shared.TimePointer(row.LatestSubmittedAt), + LatestReviewedAt: shared.TimePointer(row.LatestReviewedAt), + } +} + +func buildAssignmentCloseReadiness(queue []sqlc.ListAssignmentReviewQueueRow) assignmentCloseReadiness { + blockers := make([]string, 0) + if len(queue) == 0 { + return assignmentCloseReadiness{ + CanClose: false, + Blockers: []string{"No students have been assigned yet."}, + } + } + + for _, item := range queue { + name := strings.TrimSpace(item.StudentName) + if name == "" { + name = fmt.Sprintf("Student %d", item.StudentID) + } + + switch { + case item.SubmittedQuestions > 0 || item.ReviewStatus == sqlc.AnswerStatusSubmitted: + blockers = append(blockers, fmt.Sprintf("%s still has submitted work waiting for review.", name)) + case item.InProgressQuestions > 0 || item.ReviewStatus == sqlc.AnswerStatusInProgress: + blockers = append(blockers, fmt.Sprintf("%s still has work in progress.", name)) + case item.AnsweredQuestions == 0 || item.ReviewStatus == sqlc.AnswerStatusNotStarted: + blockers = append(blockers, fmt.Sprintf("%s has not started this assignment yet.", name)) + case !item.NextStepOutcome.Valid: + blockers = append(blockers, fmt.Sprintf("%s still needs a next-step decision.", name)) + } + } + + return assignmentCloseReadiness{ + CanClose: len(blockers) == 0, + Blockers: blockers, + } +} + +func parseStoredRedoPlan(value string) (storedRedoPlan, error) { + var payload storedRedoPlan + if err := json.Unmarshal([]byte(value), &payload); err != nil { + return storedRedoPlan{}, err + } + return payload, nil +} + +func mapWeaknessSummary(studentID int64, summary weaknessSummary) StudentWeaknessSummaryResponse { + return StudentWeaknessSummaryResponse{ + StudentID: studentID, + TopicScores: summary.TopicScores, + WeakTags: summary.WeakTags, + RecentIssues: summary.RecentIssues, + } +} + +func planningScore(isCorrect pgtype.Bool, understanding pgtype.Numeric) float64 { + understandingValue := 0.0 + if value := shared.NumericPointer(understanding); value != nil { + understandingValue = *value + } + correctnessValue := 0.0 + if isCorrect.Valid && isCorrect.Bool { + correctnessValue = 1.0 + } + return (correctnessValue + understandingValue) / 2 +} + +func roundToOneDecimal(value float64) float64 { + return math.Round(value*10) / 10 +} + +func allowedQuestionTopics() []string { + return []string{ + string(sqlc.QuestionTopicPlaceValue), + string(sqlc.QuestionTopicArithmetic), + string(sqlc.QuestionTopicNegativeNumbers), + string(sqlc.QuestionTopicBidmas), + string(sqlc.QuestionTopicFractions), + string(sqlc.QuestionTopicAlgebra), + string(sqlc.QuestionTopicGeometry), + string(sqlc.QuestionTopicData), + } +} + +func emptyStringPointer(value string) *string { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + return &value +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func isValidAssignmentPassStatus(value string) bool { + switch value { + case string(sqlc.AssignmentPassStatusPending), string(sqlc.AssignmentPassStatusPass), string(sqlc.AssignmentPassStatusNoPass): + return true + default: + return false + } +} + +func isValidAssignmentNextStepOutcome(value string) bool { + switch value { + case "redo", "accept", "support": + return true + default: + return false + } +} + +func pointerToFloat64(value float64) *float64 { + return &value +} diff --git a/Backend/internal/handlers/api/assignments/handler_types.go b/Backend/internal/handlers/api/assignments/handler_types.go new file mode 100644 index 0000000..8833e73 --- /dev/null +++ b/Backend/internal/handlers/api/assignments/handler_types.go @@ -0,0 +1,236 @@ +package assignments + +import ( + "boostai-backend/internal/aireview" + "time" +) + +type AssignmentResponse struct { + ID int64 `json:"id"` + ClassroomID int64 `json:"classroom_id"` + TeacherID int64 `json:"teacher_id"` + Title string `json:"title"` + Instructions *string `json:"instructions,omitempty"` + PassThreshold *float64 `json:"pass_threshold,omitempty"` + Status string `json:"status"` + DueAt *time.Time `json:"due_at,omitempty"` + PublishedAt *time.Time `json:"published_at,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +type AssignmentQuestionResponse struct { + AssignmentID int64 `json:"assignment_id"` + QuestionID int64 `json:"question_id"` + Position int32 `json:"position"` + AuthorTeacherID int64 `json:"author_teacher_id"` + Title string `json:"title"` + Prompt string `json:"prompt"` + Subject *string `json:"subject,omitempty"` + Source *string `json:"source,omitempty"` + QuestionStatus string `json:"question_status"` + QuestionCreatedAt *time.Time `json:"question_created_at,omitempty"` + QuestionUpdatedAt *time.Time `json:"question_updated_at,omitempty"` +} + +type AssignmentStudentQuestionDetailResponse struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + QuestionID int64 `json:"question_id"` + Position int32 `json:"position"` + Title string `json:"title"` + Prompt string `json:"prompt"` + Subject *string `json:"subject,omitempty"` + Source *string `json:"source,omitempty"` + QuestionTags []string `json:"question_tags,omitempty"` + QuestionStatus string `json:"question_status"` + CorrectAnswer *string `json:"correct_answer,omitempty"` + AssignmentAiFeedback *string `json:"assignment_ai_feedback,omitempty"` + AssignmentTeacherFeedback *string `json:"assignment_teacher_feedback,omitempty"` + OverallScore *float64 `json:"overall_score,omitempty"` + PassThreshold *float64 `json:"pass_threshold,omitempty"` + NextStepOutcome *string `json:"next_step_outcome,omitempty"` + PassStatusOverride *string `json:"pass_status_override,omitempty"` + PassStatus *string `json:"pass_status,omitempty"` + AnswerID *int64 `json:"answer_id,omitempty"` + AnswerText *string `json:"answer_text,omitempty"` + SolveMode *string `json:"solve_mode,omitempty"` + WorkingSteps *string `json:"working_steps,omitempty"` + IsCorrect *bool `json:"is_correct,omitempty"` + AiFeedback *string `json:"ai_feedback,omitempty"` + TeacherFeedback *string `json:"teacher_feedback,omitempty"` + AnswerStatus *string `json:"answer_status,omitempty"` + ReviewNeedsAttention *bool `json:"review_needs_attention,omitempty"` + ReviewIssueReason *string `json:"review_issue_reason,omitempty"` + ReviewCorrectnessScore *float64 `json:"review_correctness_score,omitempty"` + ReviewUnderstandingScore *float64 `json:"review_understanding_score,omitempty"` + ReviewQuestionScore *float64 `json:"review_question_score,omitempty"` + ReviewConfidence *float64 `json:"review_confidence,omitempty"` + ReviewTags []string `json:"review_tags,omitempty"` + SubmittedAt *time.Time `json:"submitted_at,omitempty"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + AnswerCreatedAt *time.Time `json:"answer_created_at,omitempty"` + AnswerUpdatedAt *time.Time `json:"answer_updated_at,omitempty"` +} + +type updateAssignmentTeacherFeedbackRequest struct { + TeacherFeedback *string `json:"teacher_feedback"` + PassStatusOverride *string `json:"pass_status_override"` + NextStepOutcome *string `json:"next_step_outcome"` +} + +type updateAssignmentDraftRequest struct { + ClassroomID int64 `json:"classroom_id"` + Title string `json:"title"` + Instructions *string `json:"instructions"` + PassThreshold *float64 `json:"pass_threshold"` + DueAt *time.Time `json:"due_at"` +} + +type AssignmentReviewSummaryResponse struct { + AssignmentID int64 `json:"assignment_id"` + TotalQuestions int64 `json:"total_questions"` + TotalAssigned int64 `json:"total_assigned"` + NotStarted int64 `json:"not_started"` + InProgress int64 `json:"in_progress"` + Submitted int64 `json:"submitted"` + Reviewed int64 `json:"reviewed"` +} + +type AssignmentReviewQueueItemResponse struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + NextStepOutcome *string `json:"next_step_outcome,omitempty"` + StudentName string `json:"student_name"` + StudentEmail string `json:"student_email"` + TotalQuestions int64 `json:"total_questions"` + AnsweredQuestions int64 `json:"answered_questions"` + ReviewedQuestions int64 `json:"reviewed_questions"` + SubmittedQuestions int64 `json:"submitted_questions"` + InProgressQuestions int64 `json:"in_progress_questions"` + ReviewStatus string `json:"review_status"` + LatestSubmittedAt *time.Time `json:"latest_submitted_at,omitempty"` + LatestReviewedAt *time.Time `json:"latest_reviewed_at,omitempty"` +} + +type assignmentCloseReadiness struct { + CanClose bool + Blockers []string +} + +type StudentWeaknessSummaryResponse struct { + StudentID int64 `json:"student_id"` + TopicScores map[string]float64 `json:"topic_scores"` + WeakTags []string `json:"weak_tags"` + RecentIssues []string `json:"recent_issues"` +} + +type AssignmentRedoPlanResponse struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + RedoPlanGeneratedAt *time.Time `json:"redo_plan_generated_at,omitempty"` + TeacherFeedback *string `json:"teacher_feedback,omitempty"` + WeaknessSummary StudentWeaknessSummaryResponse `json:"weakness_summary"` + Plan *aireview.RedoPlanResult `json:"plan,omitempty"` + Error string `json:"error,omitempty"` +} + +type createAssignmentRequest struct { + ClassroomID int64 `json:"classroom_id"` + TeacherID int64 `json:"teacher_id"` + Title string `json:"title"` + Instructions *string `json:"instructions"` + PassThreshold *float64 `json:"pass_threshold"` + Status string `json:"status"` + DueAt *time.Time `json:"due_at"` + PublishedAt *time.Time `json:"published_at"` +} + +type assignStudentToAssignmentRequest struct { + StudentID int64 `json:"student_id"` + MixedGeneration *generateMixedStudentQuestionsRequest `json:"mixed_generation"` +} + +type addQuestionToAssignmentRequest struct { + QuestionID int64 `json:"question_id"` + Position int32 `json:"position"` +} + +type generateMixedStudentQuestionsRequest struct { + PrimaryTopic string `json:"primary_topic"` + PrimaryDifficulty string `json:"primary_difficulty"` + TotalQuestions int `json:"total_questions"` + PersonalizedRatio *float64 `json:"personalized_ratio"` + Seed *int64 `json:"seed"` + PersonalizedDifficulty *string `json:"personalized_difficulty"` + Subject *string `json:"subject"` + QuestionStatus *string `json:"question_status"` + QuestionSource *string `json:"question_source"` +} + +type mixedPlanWeaknessSummaryResponse struct { + TopicScores map[string]float64 `json:"topic_scores"` + WeakTags []string `json:"weak_tags"` + RecentIssues []string `json:"recent_issues"` +} + +type mixedPlanQuestionResponse struct { + MappingID int64 `json:"mapping_id"` + QuestionID int64 `json:"question_id"` + Position int32 `json:"position"` + SourceBucket string `json:"source_bucket"` + SourceTopic *string `json:"source_topic,omitempty"` + SourceDifficulty *string `json:"source_difficulty,omitempty"` + GeneratorSeed *int64 `json:"generator_seed,omitempty"` + Title string `json:"title"` + Prompt string `json:"prompt"` + Subject *string `json:"subject,omitempty"` + QuestionStatus string `json:"question_status"` + QuestionSource *string `json:"question_source,omitempty"` + CorrectAnswer *string `json:"correct_answer,omitempty"` + Tags []string `json:"tags,omitempty"` + QuestionCreatedAt *time.Time `json:"question_created_at,omitempty"` + QuestionUpdatedAt *time.Time `json:"question_updated_at,omitempty"` +} + +type generateMixedStudentQuestionsResponse struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + PrimaryTopic string `json:"primary_topic"` + PrimaryDifficulty string `json:"primary_difficulty"` + TotalQuestions int `json:"total_questions"` + CoreCount int `json:"core_count"` + PersonalizedCount int `json:"personalized_count"` + PersonalizedApplied bool `json:"personalized_applied"` + PersonalizedTopic *string `json:"personalized_topic,omitempty"` + PersonalizedRatio float64 `json:"personalized_ratio"` + BaseSeed int64 `json:"base_seed"` + WeaknessSummary mixedPlanWeaknessSummaryResponse `json:"weakness_summary"` + Questions []mixedPlanQuestionResponse `json:"questions"` +} + +type assignmentAPIError struct { + status int + code string + message string +} + +func (e *assignmentAPIError) Error() string { + if e == nil { + return "" + } + return e.message +} + +type weaknessSummary struct { + TopicScores map[string]float64 `json:"topicScores"` + WeakTags []string `json:"weakTags"` + RecentIssues []string `json:"recentIssues"` +} + +type storedRedoPlan struct { + TeacherFeedback string `json:"teacherFeedback,omitempty"` + WeaknessSummary weaknessSummary `json:"weaknessSummary"` + Plan *aireview.RedoPlanResult `json:"plan,omitempty"` + Error string `json:"error,omitempty"` +} diff --git a/Backend/internal/handlers/api/assignments/routes.go b/Backend/internal/handlers/api/assignments/routes.go new file mode 100644 index 0000000..91fc371 --- /dev/null +++ b/Backend/internal/handlers/api/assignments/routes.go @@ -0,0 +1,25 @@ +package assignments + +import ( + authmw "boostai-backend/internal/middleware" + + "github.com/gofiber/fiber/v2" +) + +func RegisterRoutes(app fiber.Router, auth *authmw.AuthMiddleware, h *Handler) { + app.Get("/teachers/:teacherId/assignments", auth.RequireTeacherSelf("teacherId"), h.ListAssignmentsByTeacher) + app.Get("/students/:studentId/assignments", auth.RequireStudentSelfOrTeacher("studentId"), h.ListAssignmentsForStudent) + app.Get("/assignments/:assignmentId", h.GetAssignmentByID) + app.Get("/assignments/:assignmentId/questions", h.ListQuestionsForAssignment) + app.Get("/assignments/:assignmentId/students/:studentId/questions", auth.RequireStudentSelfOrTeacher("studentId"), h.ListQuestionDetailsForAssignmentStudent) + app.Get("/assignments/:assignmentId/students/:studentId/redo-plan", auth.RequireTeacher(), h.GetAssignmentRedoPlan) + app.Post("/assignments/:assignmentId/students/:studentId/generate-mixed-questions", auth.RequireTeacher(), h.GenerateMixedStudentQuestions) + app.Patch("/assignments/:assignmentId", auth.RequireTeacher(), h.UpdateAssignmentDraft) + app.Post("/assignments/:assignmentId/close", auth.RequireTeacher(), h.CloseAssignment) + app.Patch("/assignments/:assignmentId/students/:studentId/feedback", auth.RequireTeacher(), h.UpdateAssignmentTeacherFeedback) + app.Get("/assignments/:assignmentId/review-summary", auth.RequireTeacher(), h.GetAssignmentReviewSummary) + app.Get("/assignments/:assignmentId/review", auth.RequireTeacher(), h.ListAssignmentReviewQueue) + app.Post("/assignments", auth.RequireTeacher(), h.CreateAssignment) + app.Post("/assignments/:assignmentId/students", auth.RequireTeacher(), h.AssignStudentToAssignment) + app.Post("/assignments/:assignmentId/questions", auth.RequireTeacher(), h.AddQuestionToAssignment) +} diff --git a/Backend/internal/handlers/api/classrooms/handler.go b/Backend/internal/handlers/api/classrooms/handler.go new file mode 100644 index 0000000..fda1c0d --- /dev/null +++ b/Backend/internal/handlers/api/classrooms/handler.go @@ -0,0 +1,180 @@ +package classrooms + +import ( + "boostai-backend/internal/handlers/api/shared" + "boostai-backend/internal/http/params" + "boostai-backend/internal/http/respond" + authmw "boostai-backend/internal/middleware" + "boostai-backend/internal/sqlc" + "strings" + "time" + + "github.com/gofiber/fiber/v2" +) + +type Handler struct { + queries *sqlc.Queries +} + +type ClassroomResponse struct { + ID int64 `json:"id"` + TeacherID int64 `json:"teacher_id"` + Name string `json:"name"` + Code *string `json:"code,omitempty"` + Description *string `json:"description,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +type StudentResponse struct { + ID int64 `json:"id"` + Email string `json:"email"` + Role string `json:"role"` + FullName string `json:"full_name"` + IsActive bool `json:"is_active"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +type createClassroomRequest struct { + TeacherID int64 `json:"teacher_id"` + Name string `json:"name"` + Code *string `json:"code"` + Description *string `json:"description"` +} + +type addStudentToClassroomRequest struct { + StudentID int64 `json:"student_id"` +} + +func NewHandler(queries *sqlc.Queries) *Handler { + return &Handler{queries: queries} +} + +func (h *Handler) ListClassroomsByTeacher(c *fiber.Ctx) error { + teacherID, err := params.Int64PathParam(c, "teacherId") + if err != nil { + return err + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + classrooms, err := h.queries.ListClassroomsByTeacher(ctx, teacherID) + if err != nil { + return respond.DatabaseError(c, err) + } + + items := make([]ClassroomResponse, 0, len(classrooms)) + for _, classroom := range classrooms { + items = append(items, mapClassroom(classroom)) + } + + return c.JSON(shared.ListResponse[ClassroomResponse]{Data: items}) +} + +func (h *Handler) ListStudentsForClassroom(c *fiber.Ctx) error { + classroomID, err := params.Int64PathParam(c, "classroomId") + if err != nil { + return err + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + students, err := h.queries.ListStudentsForClassroom(ctx, classroomID) + if err != nil { + return respond.DatabaseError(c, err) + } + + items := make([]StudentResponse, 0, len(students)) + for _, student := range students { + items = append(items, mapStudent(student)) + } + + return c.JSON(shared.ListResponse[StudentResponse]{Data: items}) +} + +func (h *Handler) CreateClassroom(c *fiber.Ctx) error { + var req createClassroomRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + teacherID := authmw.CurrentUserID(c) + if teacherID == 0 || strings.TrimSpace(req.Name) == "" { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "teacher authentication and name are required") + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + classroom, err := h.queries.CreateClassroom(ctx, sqlc.CreateClassroomParams{ + TeacherID: teacherID, + Name: strings.TrimSpace(req.Name), + Code: shared.NullableText(req.Code), + Description: shared.NullableText(req.Description), + }) + if err != nil { + return respond.DatabaseError(c, err) + } + + return c.Status(fiber.StatusCreated).JSON(mapClassroom(classroom)) +} + +func (h *Handler) AddStudentToClassroom(c *fiber.Ctx) error { + classroomID, err := params.Int64PathParam(c, "classroomId") + if err != nil { + return err + } + + var req addStudentToClassroomRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + if req.StudentID == 0 { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "student_id is required") + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + err = h.queries.AddStudentToClassroom(ctx, sqlc.AddStudentToClassroomParams{ + ClassroomID: classroomID, + StudentID: req.StudentID, + }) + if err != nil { + return respond.DatabaseError(c, err) + } + + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "status": "ok", + "classroom_id": classroomID, + "student_id": req.StudentID, + }) +} + +func mapClassroom(classroom sqlc.Classroom) ClassroomResponse { + return ClassroomResponse{ + ID: classroom.ID, + TeacherID: classroom.TeacherID, + Name: classroom.Name, + Code: shared.TextPointer(classroom.Code), + Description: shared.TextPointer(classroom.Description), + CreatedAt: shared.TimePointer(classroom.CreatedAt), + UpdatedAt: shared.TimePointer(classroom.UpdatedAt), + } +} + +func mapStudent(user sqlc.User) StudentResponse { + return StudentResponse{ + ID: user.ID, + Email: user.Email, + Role: string(user.Role), + FullName: user.FullName, + IsActive: user.IsActive, + CreatedAt: shared.TimePointer(user.CreatedAt), + UpdatedAt: shared.TimePointer(user.UpdatedAt), + } +} diff --git a/Backend/internal/handlers/api/classrooms/routes.go b/Backend/internal/handlers/api/classrooms/routes.go new file mode 100644 index 0000000..762b5ee --- /dev/null +++ b/Backend/internal/handlers/api/classrooms/routes.go @@ -0,0 +1,14 @@ +package classrooms + +import ( + authmw "boostai-backend/internal/middleware" + + "github.com/gofiber/fiber/v2" +) + +func RegisterRoutes(app fiber.Router, auth *authmw.AuthMiddleware, h *Handler) { + app.Get("/teachers/:teacherId/classrooms", h.ListClassroomsByTeacher) + app.Get("/classrooms/:classroomId/students", h.ListStudentsForClassroom) + app.Post("/classrooms", auth.RequireTeacher(), h.CreateClassroom) + app.Post("/classrooms/:classroomId/students", auth.RequireTeacher(), h.AddStudentToClassroom) +} diff --git a/Backend/internal/handlers/api/handler.go b/Backend/internal/handlers/api/handler.go new file mode 100644 index 0000000..d8a7d92 --- /dev/null +++ b/Backend/internal/handlers/api/handler.go @@ -0,0 +1,41 @@ +package api + +import ( + "boostai-backend/internal/aireview" + "boostai-backend/internal/assignmentgen" + "boostai-backend/internal/config" + "boostai-backend/internal/database" + answershandler "boostai-backend/internal/handlers/api/answers" + assignmentshandler "boostai-backend/internal/handlers/api/assignments" + classroomshandler "boostai-backend/internal/handlers/api/classrooms" + messageshandler "boostai-backend/internal/handlers/api/messages" + questionshandler "boostai-backend/internal/handlers/api/questions" + usershandler "boostai-backend/internal/handlers/api/users" + "boostai-backend/internal/questiongen" + "boostai-backend/internal/sqlc" +) + +type Handler struct { + users *usershandler.Handler + classrooms *classroomshandler.Handler + messages *messageshandler.Handler + questions *questionshandler.Handler + assignments *assignmentshandler.Handler + answers *answershandler.Handler +} + +func NewHandler(db *database.DB, cfg *config.Config) *Handler { + queries := sqlc.New(db.Pool) + aiReviewService := aireview.NewService(cfg.AIReviewEndpoint, cfg.AIReviewAPIKey, cfg.AIReviewModel) + questionGenerator := questiongen.NewService() + assignmentGenerator := assignmentgen.NewService(db, questionGenerator) + + return &Handler{ + users: usershandler.NewHandler(queries), + classrooms: classroomshandler.NewHandler(queries), + messages: messageshandler.NewHandler(db), + questions: questionshandler.NewHandler(queries, questionGenerator), + assignments: assignmentshandler.NewHandler(queries, aiReviewService, assignmentGenerator), + answers: answershandler.NewHandler(queries, aiReviewService), + } +} diff --git a/Backend/internal/handlers/api/messages/handler.go b/Backend/internal/handlers/api/messages/handler.go new file mode 100644 index 0000000..5c045bf --- /dev/null +++ b/Backend/internal/handlers/api/messages/handler.go @@ -0,0 +1,708 @@ +package messages + +import ( + "boostai-backend/internal/database" + "boostai-backend/internal/handlers/api/shared" + "boostai-backend/internal/http/params" + "boostai-backend/internal/http/respond" + authmw "boostai-backend/internal/middleware" + "boostai-backend/internal/sqlc" + "errors" + "sort" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type Handler struct { + db *database.DB + queries *sqlc.Queries +} + +type recipientResponse struct { + ID int64 `json:"id"` + Email string `json:"email"` + Role string `json:"role"` + FullName string `json:"full_name"` + PreferredName *string `json:"preferred_name"` + ProfileIconURL *string `json:"profile_icon_url"` + Headline *string `json:"headline"` +} + +type threadParticipantResponse struct { + ID int64 `json:"id"` + Email string `json:"email"` + Role string `json:"role"` + FullName string `json:"full_name"` + PreferredName *string `json:"preferred_name"` + ProfileIconURL *string `json:"profile_icon_url"` + Headline *string `json:"headline"` + JoinedAt *time.Time `json:"joined_at,omitempty"` + LastReadAt *time.Time `json:"last_read_at,omitempty"` + ArchivedAt *time.Time `json:"archived_at,omitempty"` +} + +type messageSenderResponse struct { + ID int64 `json:"id"` + Email string `json:"email"` + Role string `json:"role"` + FullName string `json:"full_name"` + PreferredName *string `json:"preferred_name"` + ProfileIconURL *string `json:"profile_icon_url"` + Headline *string `json:"headline"` +} + +type messageResponse struct { + ID int64 `json:"id"` + ThreadID int64 `json:"thread_id"` + Body string `json:"body"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + Mine bool `json:"mine"` + Sender messageSenderResponse `json:"sender"` +} + +type messageThreadSummaryResponse struct { + ID int64 `json:"id"` + Subject string `json:"subject"` + CreatedByUserID int64 `json:"created_by_user_id"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + UnreadCount int64 `json:"unread_count"` + LastMessageID int64 `json:"last_message_id"` + LastMessageBody *string `json:"last_message_body"` + LastMessageCreatedAt *time.Time `json:"last_message_created_at,omitempty"` + LastMessageSender *messageSenderResponse `json:"last_message_sender,omitempty"` + Participants []threadParticipantResponse `json:"participants"` +} + +type messageThreadDetailResponse struct { + ID int64 `json:"id"` + Subject string `json:"subject"` + CreatedByUserID int64 `json:"created_by_user_id"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + UnreadCount int64 `json:"unread_count"` + LastReadAt *time.Time `json:"last_read_at,omitempty"` + Participants []threadParticipantResponse `json:"participants"` + Messages []messageResponse `json:"messages"` +} + +type createThreadRequest struct { + Subject string `json:"subject"` + RecipientIDs []int64 `json:"recipient_ids"` + Body string `json:"body"` +} + +type createThreadResponse struct { + ThreadID int64 `json:"thread_id"` +} + +type createThreadMessageRequest struct { + Body string `json:"body"` +} + +type updateThreadRequest struct { + Subject string `json:"subject"` +} + +type updateThreadMessageRequest struct { + Body string `json:"body"` +} + +func NewHandler(db *database.DB) *Handler { + return &Handler{db: db, queries: sqlc.New(db.Pool)} +} + +func (h *Handler) ListRecipients(c *fiber.Ctx) error { + currentUserID := authmw.CurrentUserID(c) + ctx, cancel := shared.WithTimeout() + defer cancel() + + recipients, err := h.queries.ListMessageRecipientsForUser(ctx, currentUserID) + if err != nil { + return respond.DatabaseError(c, err) + } + + items := make([]recipientResponse, 0, len(recipients)) + for _, recipient := range recipients { + items = append(items, mapRecipient(recipient)) + } + + return c.JSON(shared.ListResponse[recipientResponse]{Data: items}) +} + +func (h *Handler) ListThreads(c *fiber.Ctx) error { + currentUserID := authmw.CurrentUserID(c) + ctx, cancel := shared.WithTimeout() + defer cancel() + + threads, err := h.queries.ListMessageThreadsForUser(ctx, currentUserID) + if err != nil { + return respond.DatabaseError(c, err) + } + + participants, err := h.queries.ListMessageThreadParticipantsForUser(ctx, currentUserID) + if err != nil { + return respond.DatabaseError(c, err) + } + + participantsByThread := make(map[int64][]threadParticipantResponse) + for _, participant := range participants { + participantsByThread[participant.ThreadID] = append(participantsByThread[participant.ThreadID], mapThreadParticipant(participant)) + } + + items := make([]messageThreadSummaryResponse, 0, len(threads)) + for _, thread := range threads { + items = append(items, mapThreadSummary(thread, participantsByThread[thread.ThreadID])) + } + + return c.JSON(shared.ListResponse[messageThreadSummaryResponse]{Data: items}) +} + +func (h *Handler) GetThread(c *fiber.Ctx) error { + threadID, err := params.Int64PathParam(c, "threadId") + if err != nil { + return err + } + + thread, err := h.loadThread(threadID, authmw.CurrentUserID(c)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusNotFound, "not_found", "Message thread not found") + } + return respond.DatabaseError(c, err) + } + + return c.JSON(thread) +} + +func (h *Handler) CreateThread(c *fiber.Ctx) error { + currentUserID := authmw.CurrentUserID(c) + + var req createThreadRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + subject := strings.TrimSpace(req.Subject) + body := strings.TrimSpace(req.Body) + if subject == "" { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "subject is required") + } + + recipientIDs := normalizeRecipientIDs(currentUserID, req.RecipientIDs) + if len(recipientIDs) == 0 { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "At least one valid recipient is required") + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + tx, err := h.db.Pool.Begin(ctx) + if err != nil { + return respond.DatabaseError(c, err) + } + defer func() { + _ = tx.Rollback(ctx) + }() + + queries := h.queries.WithTx(tx) + for _, recipientID := range recipientIDs { + if _, err := queries.GetMessageRecipientByIDForUser(ctx, sqlc.GetMessageRecipientByIDForUserParams{ID: currentUserID, ID_2: recipientID}); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "One or more recipients are not available for messaging") + } + return respond.DatabaseError(c, err) + } + } + + thread, err := queries.CreateMessageThread(ctx, sqlc.CreateMessageThreadParams{ + CreatedByUserID: currentUserID, + Subject: subject, + }) + if err != nil { + return respond.DatabaseError(c, err) + } + + creatorReadAt := pgtype.Timestamptz{} + if body != "" { + message, err := queries.CreateThreadMessage(ctx, sqlc.CreateThreadMessageParams{ + ThreadID: thread.ID, + SenderUserID: currentUserID, + Body: body, + }) + if err != nil { + return respond.DatabaseError(c, err) + } + + if message.CreatedAt.Valid { + creatorReadAt = pgtype.Timestamptz{Time: message.CreatedAt.Time.UTC(), Valid: true} + } + } + + if err := queries.AddMessageThreadParticipant(ctx, sqlc.AddMessageThreadParticipantParams{ + ThreadID: thread.ID, + UserID: currentUserID, + LastReadAt: creatorReadAt, + }); err != nil { + return respond.DatabaseError(c, err) + } + + for _, recipientID := range recipientIDs { + if err := queries.AddMessageThreadParticipant(ctx, sqlc.AddMessageThreadParticipantParams{ + ThreadID: thread.ID, + UserID: recipientID, + }); err != nil { + return respond.DatabaseError(c, err) + } + } + + if err := queries.TouchMessageThread(ctx, thread.ID); err != nil { + return respond.DatabaseError(c, err) + } + + if err := tx.Commit(ctx); err != nil { + return respond.DatabaseError(c, err) + } + + return c.Status(fiber.StatusCreated).JSON(createThreadResponse{ThreadID: thread.ID}) +} + +func (h *Handler) CreateThreadMessage(c *fiber.Ctx) error { + threadID, err := params.Int64PathParam(c, "threadId") + if err != nil { + return err + } + + currentUserID := authmw.CurrentUserID(c) + var req createThreadMessageRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + body := strings.TrimSpace(req.Body) + if body == "" { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "body is required") + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + tx, err := h.db.Pool.Begin(ctx) + if err != nil { + return respond.DatabaseError(c, err) + } + defer func() { + _ = tx.Rollback(ctx) + }() + + queries := h.queries.WithTx(tx) + if _, err := queries.GetMessageThreadForUser(ctx, sqlc.GetMessageThreadForUserParams{ID: threadID, SenderUserID: currentUserID}); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusNotFound, "not_found", "Message thread not found") + } + return respond.DatabaseError(c, err) + } + + if _, err := queries.CreateThreadMessage(ctx, sqlc.CreateThreadMessageParams{ + ThreadID: threadID, + SenderUserID: currentUserID, + Body: body, + }); err != nil { + return respond.DatabaseError(c, err) + } + + if err := queries.TouchMessageThread(ctx, threadID); err != nil { + return respond.DatabaseError(c, err) + } + + if _, err := queries.MarkMessageThreadRead(ctx, sqlc.MarkMessageThreadReadParams{ThreadID: threadID, UserID: currentUserID}); err != nil { + return respond.DatabaseError(c, err) + } + + if err := tx.Commit(ctx); err != nil { + return respond.DatabaseError(c, err) + } + + return c.Status(fiber.StatusCreated).JSON(fiber.Map{"status": "ok"}) +} + +func (h *Handler) UpdateThread(c *fiber.Ctx) error { + threadID, err := params.Int64PathParam(c, "threadId") + if err != nil { + return err + } + + currentUserID := authmw.CurrentUserID(c) + var req updateThreadRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + subject := strings.TrimSpace(req.Subject) + if subject == "" { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "subject is required") + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + thread, err := h.queries.GetMessageThreadForUser(ctx, sqlc.GetMessageThreadForUserParams{ID: threadID, SenderUserID: currentUserID}) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusNotFound, "not_found", "Message thread not found") + } + return respond.DatabaseError(c, err) + } + + if thread.CreatedByUserID != currentUserID { + return respond.Error(c, fiber.StatusForbidden, "forbidden", "Only the conversation starter can edit the thread title") + } + + if _, err := h.queries.UpdateMessageThreadSubject(ctx, sqlc.UpdateMessageThreadSubjectParams{ + ThreadID: threadID, + Subject: subject, + }); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusNotFound, "not_found", "Message thread not found") + } + return respond.DatabaseError(c, err) + } + + return c.JSON(fiber.Map{"status": "ok"}) +} + +func (h *Handler) DeleteThread(c *fiber.Ctx) error { + threadID, err := params.Int64PathParam(c, "threadId") + if err != nil { + return err + } + + currentUserID := authmw.CurrentUserID(c) + ctx, cancel := shared.WithTimeout() + defer cancel() + + thread, err := h.queries.GetMessageThreadForUser(ctx, sqlc.GetMessageThreadForUserParams{ID: threadID, SenderUserID: currentUserID}) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusNotFound, "not_found", "Message thread not found") + } + return respond.DatabaseError(c, err) + } + + if thread.CreatedByUserID != currentUserID { + return respond.Error(c, fiber.StatusForbidden, "forbidden", "Only the conversation starter can delete this conversation") + } + + if _, err := h.queries.DeleteMessageThread(ctx, threadID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusNotFound, "not_found", "Message thread not found") + } + return respond.DatabaseError(c, err) + } + + return c.JSON(fiber.Map{"status": "ok"}) +} + +func (h *Handler) UpdateThreadMessage(c *fiber.Ctx) error { + threadID, err := params.Int64PathParam(c, "threadId") + if err != nil { + return err + } + + messageID, err := params.Int64PathParam(c, "messageId") + if err != nil { + return err + } + + currentUserID := authmw.CurrentUserID(c) + var req updateThreadMessageRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + body := strings.TrimSpace(req.Body) + if body == "" { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "body is required") + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + tx, err := h.db.Pool.Begin(ctx) + if err != nil { + return respond.DatabaseError(c, err) + } + defer func() { + _ = tx.Rollback(ctx) + }() + + queries := h.queries.WithTx(tx) + if _, err := queries.UpdateThreadMessageBody(ctx, sqlc.UpdateThreadMessageBodyParams{ + Body: body, + MessageID: messageID, + ThreadID: threadID, + UserID: currentUserID, + }); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusNotFound, "not_found", "Message not found") + } + return respond.DatabaseError(c, err) + } + + if err := queries.TouchMessageThread(ctx, threadID); err != nil { + return respond.DatabaseError(c, err) + } + + if err := tx.Commit(ctx); err != nil { + return respond.DatabaseError(c, err) + } + + return c.JSON(fiber.Map{"status": "ok"}) +} + +func (h *Handler) DeleteThreadMessage(c *fiber.Ctx) error { + threadID, err := params.Int64PathParam(c, "threadId") + if err != nil { + return err + } + + messageID, err := params.Int64PathParam(c, "messageId") + if err != nil { + return err + } + + currentUserID := authmw.CurrentUserID(c) + ctx, cancel := shared.WithTimeout() + defer cancel() + + tx, err := h.db.Pool.Begin(ctx) + if err != nil { + return respond.DatabaseError(c, err) + } + defer func() { + _ = tx.Rollback(ctx) + }() + + queries := h.queries.WithTx(tx) + if _, err := queries.DeleteThreadMessage(ctx, sqlc.DeleteThreadMessageParams{ + MessageID: messageID, + ThreadID: threadID, + UserID: currentUserID, + }); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusNotFound, "not_found", "Message not found") + } + return respond.DatabaseError(c, err) + } + + if err := queries.TouchMessageThread(ctx, threadID); err != nil { + return respond.DatabaseError(c, err) + } + + if err := tx.Commit(ctx); err != nil { + return respond.DatabaseError(c, err) + } + + return c.JSON(fiber.Map{"status": "ok"}) +} + +func (h *Handler) MarkThreadRead(c *fiber.Ctx) error { + threadID, err := params.Int64PathParam(c, "threadId") + if err != nil { + return err + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + if _, err := h.queries.MarkMessageThreadRead(ctx, sqlc.MarkMessageThreadReadParams{ThreadID: threadID, UserID: authmw.CurrentUserID(c)}); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusNotFound, "not_found", "Message thread not found") + } + return respond.DatabaseError(c, err) + } + + return c.JSON(fiber.Map{"status": "ok"}) +} + +func (h *Handler) loadThread(threadID, currentUserID int64) (messageThreadDetailResponse, error) { + queryCtx, cancel := shared.WithTimeout() + defer cancel() + + thread, err := h.queries.GetMessageThreadForUser(queryCtx, sqlc.GetMessageThreadForUserParams{ID: threadID, SenderUserID: currentUserID}) + if err != nil { + return messageThreadDetailResponse{}, err + } + + participants, err := h.queries.ListParticipantsForThreadForUser(queryCtx, sqlc.ListParticipantsForThreadForUserParams{ThreadID: threadID, UserID: currentUserID}) + if err != nil { + return messageThreadDetailResponse{}, err + } + + messages, err := h.queries.ListMessagesForThreadForUser(queryCtx, sqlc.ListMessagesForThreadForUserParams{ThreadID: threadID, UserID: currentUserID}) + if err != nil { + return messageThreadDetailResponse{}, err + } + + participantItems := make([]threadParticipantResponse, 0, len(participants)) + for _, participant := range participants { + participantItems = append(participantItems, mapThreadParticipantByThread(participant)) + } + + messageItems := make([]messageResponse, 0, len(messages)) + for _, message := range messages { + messageItems = append(messageItems, mapThreadMessage(message, currentUserID)) + } + + return messageThreadDetailResponse{ + ID: thread.ID, + Subject: thread.Subject, + CreatedByUserID: thread.CreatedByUserID, + CreatedAt: shared.TimePointer(thread.CreatedAt), + UpdatedAt: shared.TimePointer(thread.UpdatedAt), + UnreadCount: thread.UnreadCount, + LastReadAt: shared.TimePointer(thread.LastReadAt), + Participants: participantItems, + Messages: messageItems, + }, nil +} + +func mapRecipient(row sqlc.ListMessageRecipientsForUserRow) recipientResponse { + return recipientResponse{ + ID: row.UserID, + Email: row.UserEmail, + Role: string(row.UserRole), + FullName: row.UserFullName, + PreferredName: shared.TextPointer(row.PreferredName), + ProfileIconURL: shared.TextPointer(row.ProfileIconUrl), + Headline: shared.TextPointer(row.Headline), + } +} + +func mapRecipientByID(row sqlc.GetMessageRecipientByIDForUserRow) recipientResponse { + return recipientResponse{ + ID: row.UserID, + Email: row.UserEmail, + Role: string(row.UserRole), + FullName: row.UserFullName, + PreferredName: shared.TextPointer(row.PreferredName), + ProfileIconURL: shared.TextPointer(row.ProfileIconUrl), + Headline: shared.TextPointer(row.Headline), + } +} + +func mapThreadParticipant(row sqlc.ListMessageThreadParticipantsForUserRow) threadParticipantResponse { + return threadParticipantResponse{ + ID: row.UserID, + Email: row.UserEmail, + Role: string(row.UserRole), + FullName: row.UserFullName, + PreferredName: shared.TextPointer(row.PreferredName), + ProfileIconURL: shared.TextPointer(row.ProfileIconUrl), + Headline: shared.TextPointer(row.Headline), + JoinedAt: shared.TimePointer(row.JoinedAt), + LastReadAt: shared.TimePointer(row.LastReadAt), + ArchivedAt: shared.TimePointer(row.ArchivedAt), + } +} + +func mapThreadParticipantByThread(row sqlc.ListParticipantsForThreadForUserRow) threadParticipantResponse { + return threadParticipantResponse{ + ID: row.UserID, + Email: row.UserEmail, + Role: string(row.UserRole), + FullName: row.UserFullName, + PreferredName: shared.TextPointer(row.PreferredName), + ProfileIconURL: shared.TextPointer(row.ProfileIconUrl), + Headline: shared.TextPointer(row.Headline), + JoinedAt: shared.TimePointer(row.JoinedAt), + LastReadAt: shared.TimePointer(row.LastReadAt), + ArchivedAt: shared.TimePointer(row.ArchivedAt), + } +} + +func mapThreadSummary(row sqlc.ListMessageThreadsForUserRow, participants []threadParticipantResponse) messageThreadSummaryResponse { + response := messageThreadSummaryResponse{ + ID: row.ThreadID, + Subject: row.Subject, + CreatedByUserID: row.CreatedByUserID, + CreatedAt: shared.TimePointer(row.ThreadCreatedAt), + UpdatedAt: shared.TimePointer(row.ThreadUpdatedAt), + UnreadCount: row.UnreadCount, + LastMessageID: row.LastMessageID, + LastMessageBody: stringPointerOrNil(row.LastMessageBody), + LastMessageCreatedAt: shared.TimePointer(row.LastMessageCreatedAt), + Participants: participants, + } + + if row.LastMessageID > 0 { + response.LastMessageSender = &messageSenderResponse{ + ID: row.LastMessageSenderUserID, + Email: "", + Role: "", + FullName: valueOrEmpty(row.LastMessageSenderFullName), + PreferredName: shared.TextPointer(row.LastMessageSenderPreferredName), + ProfileIconURL: shared.TextPointer(row.LastMessageSenderProfileIconUrl), + } + } + + return response +} + +func mapThreadMessage(row sqlc.ListMessagesForThreadForUserRow, currentUserID int64) messageResponse { + return messageResponse{ + ID: row.ID, + ThreadID: row.ThreadID, + Body: row.Body, + CreatedAt: shared.TimePointer(row.CreatedAt), + UpdatedAt: shared.TimePointer(row.UpdatedAt), + Mine: row.SenderUserID == currentUserID, + Sender: messageSenderResponse{ + ID: row.SenderUserID, + Email: row.SenderEmail, + Role: string(row.SenderRole), + FullName: row.SenderFullName, + PreferredName: shared.TextPointer(row.SenderPreferredName), + ProfileIconURL: shared.TextPointer(row.SenderProfileIconUrl), + Headline: shared.TextPointer(row.SenderHeadline), + }, + } +} + +func normalizeRecipientIDs(currentUserID int64, values []int64) []int64 { + seen := make(map[int64]struct{}, len(values)) + normalized := make([]int64, 0, len(values)) + for _, value := range values { + if value <= 0 || value == currentUserID { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + normalized = append(normalized, value) + } + + sort.Slice(normalized, func(i, j int) bool { return normalized[i] < normalized[j] }) + return normalized +} + +func valueOrEmpty(value pgtype.Text) string { + if !value.Valid { + return "" + } + return value.String +} + +func stringPointerOrNil(value string) *string { + if strings.TrimSpace(value) == "" { + return nil + } + copy := value + return © +} diff --git a/Backend/internal/handlers/api/messages/routes.go b/Backend/internal/handlers/api/messages/routes.go new file mode 100644 index 0000000..01e97c3 --- /dev/null +++ b/Backend/internal/handlers/api/messages/routes.go @@ -0,0 +1,20 @@ +package messages + +import ( + authmw "boostai-backend/internal/middleware" + + "github.com/gofiber/fiber/v2" +) + +func RegisterRoutes(app fiber.Router, auth *authmw.AuthMiddleware, h *Handler) { + app.Get("/messages/recipients", h.ListRecipients) + app.Get("/messages/threads", h.ListThreads) + app.Get("/messages/threads/:threadId", h.GetThread) + app.Post("/messages/threads", h.CreateThread) + app.Patch("/messages/threads/:threadId", h.UpdateThread) + app.Delete("/messages/threads/:threadId", h.DeleteThread) + app.Post("/messages/threads/:threadId/messages", h.CreateThreadMessage) + app.Patch("/messages/threads/:threadId/messages/:messageId", h.UpdateThreadMessage) + app.Delete("/messages/threads/:threadId/messages/:messageId", h.DeleteThreadMessage) + app.Patch("/messages/threads/:threadId/read", h.MarkThreadRead) +} diff --git a/Backend/internal/handlers/api/questions/handler.go b/Backend/internal/handlers/api/questions/handler.go new file mode 100644 index 0000000..22cf0e0 --- /dev/null +++ b/Backend/internal/handlers/api/questions/handler.go @@ -0,0 +1,506 @@ +// Path: Backend/internal/handlers/api/questions/handler.go + +package questions + +import ( + "boostai-backend/internal/handlers/api/shared" + "boostai-backend/internal/http/params" + "boostai-backend/internal/http/respond" + authmw "boostai-backend/internal/middleware" + "boostai-backend/internal/questiongen" + "boostai-backend/internal/sqlc" + "errors" + "fmt" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +type Handler struct { + queries *sqlc.Queries + generator *questiongen.Service +} + +type QuestionResponse struct { + ID int64 `json:"id"` + AuthorTeacherID int64 `json:"author_teacher_id"` + Title string `json:"title"` + Prompt string `json:"prompt"` + Topic *string `json:"topic,omitempty"` + Subject *string `json:"subject,omitempty"` + Difficulty *string `json:"difficulty,omitempty"` + Source *string `json:"source,omitempty"` + CorrectAnswer *string `json:"correct_answer,omitempty"` + Status string `json:"status"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +type TagResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + CreatedAt *time.Time `json:"created_at,omitempty"` +} + +type createQuestionRequest struct { + AuthorTeacherID int64 `json:"author_teacher_id"` + Title string `json:"title"` + Prompt string `json:"prompt"` + Topic *string `json:"topic"` + Subject *string `json:"subject"` + Difficulty *string `json:"difficulty"` + Source *string `json:"source"` + CorrectAnswer *string `json:"correct_answer"` + Status string `json:"status"` +} + +type createTagRequest struct { + Name string `json:"name"` +} + +type attachTagToQuestionRequest struct { + TagID int64 `json:"tag_id"` +} + +type generateQuestionsRequest struct { + Topic string `json:"topic"` + Difficulty string `json:"difficulty"` + Count int `json:"count"` + Seed *int64 `json:"seed"` + Status *string `json:"status"` + Source *string `json:"source"` +} + +type GeneratedQuestionResponse struct { + Question QuestionResponse `json:"question"` + Tags []string `json:"tags"` + WorkedSolution []string `json:"worked_solution"` +} + +type GenerateQuestionsResponse struct { + Seed int64 `json:"seed"` + Data []GeneratedQuestionResponse `json:"data"` + Count int `json:"count"` +} + +func NewHandler(queries *sqlc.Queries, generator *questiongen.Service) *Handler { + return &Handler{queries: queries, generator: generator} +} + +func (h *Handler) ListQuestionsByTeacher(c *fiber.Ctx) error { + teacherID, err := params.Int64PathParam(c, "teacherId") + if err != nil { + return err + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + questions, err := h.queries.ListQuestionsByTeacher(ctx, teacherID) + if err != nil { + return respond.DatabaseError(c, err) + } + + items := make([]QuestionResponse, 0, len(questions)) + for _, question := range questions { + items = append(items, mapQuestion(question)) + } + + return c.JSON(shared.ListResponse[QuestionResponse]{Data: items}) +} + +func (h *Handler) GetQuestionByID(c *fiber.Ctx) error { + questionID, err := params.Int64PathParam(c, "questionId") + if err != nil { + return err + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + question, err := h.queries.GetQuestionByID(ctx, questionID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusNotFound, "not_found", "Question not found") + } + return respond.DatabaseError(c, err) + } + + return c.JSON(mapQuestion(question)) +} + +func (h *Handler) ListTags(c *fiber.Ctx) error { + ctx, cancel := shared.WithTimeout() + defer cancel() + + tags, err := h.queries.ListTags(ctx) + if err != nil { + return respond.DatabaseError(c, err) + } + + items := make([]TagResponse, 0, len(tags)) + for _, tag := range tags { + items = append(items, mapTag(tag)) + } + + return c.JSON(shared.ListResponse[TagResponse]{Data: items}) +} + +func (h *Handler) CreateQuestion(c *fiber.Ctx) error { + var req createQuestionRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + teacherID := authmw.CurrentUserID(c) + if teacherID == 0 || strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.Prompt) == "" || strings.TrimSpace(req.Status) == "" { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "teacher authentication, title, prompt, and status are required") + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + topic, subject, err := parseQuestionTopic(req.Topic, req.Subject) + if err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", err.Error()) + } + + difficulty, err := parseQuestionDifficulty(req.Difficulty) + if err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", err.Error()) + } + + question, err := h.queries.CreateQuestion(ctx, sqlc.CreateQuestionParams{ + AuthorTeacherID: teacherID, + Title: strings.TrimSpace(req.Title), + Prompt: strings.TrimSpace(req.Prompt), + Topic: topic, + Subject: shared.NullableText(subject), + Difficulty: difficulty, + Source: shared.NullableText(req.Source), + CorrectAnswer: shared.NullableText(req.CorrectAnswer), + Status: sqlc.QuestionStatus(strings.TrimSpace(req.Status)), + }) + if err != nil { + return respond.DatabaseError(c, err) + } + + return c.Status(fiber.StatusCreated).JSON(mapQuestion(question)) +} + +func (h *Handler) GenerateQuestions(c *fiber.Ctx) error { + if h.generator == nil { + return respond.Error(c, fiber.StatusServiceUnavailable, "generator_unavailable", "Question generator is not available") + } + + var req generateQuestionsRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + teacherID := authmw.CurrentUserID(c) + if teacherID == 0 { + return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "teacher authentication is required") + } + + if strings.TrimSpace(req.Topic) == "" || strings.TrimSpace(req.Difficulty) == "" { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "topic and difficulty are required") + } + + if req.Count < 1 || req.Count > 25 { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "count must be between 1 and 25") + } + + topic, subject, err := parseQuestionTopic(&req.Topic, nil) + if err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", err.Error()) + } + if !topic.Valid { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "topic is required") + } + + difficulty, err := parseQuestionDifficulty(&req.Difficulty) + if err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", err.Error()) + } + if !difficulty.Valid { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "difficulty is required") + } + + status := sqlc.QuestionStatusDraft + if req.Status != nil && strings.TrimSpace(*req.Status) != "" { + normalizedStatus := sqlc.QuestionStatus(strings.ToLower(strings.TrimSpace(*req.Status))) + switch normalizedStatus { + case sqlc.QuestionStatusDraft, sqlc.QuestionStatusPublished, sqlc.QuestionStatusArchived: + status = normalizedStatus + default: + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "status must be draft, published, or archived") + } + } + + seed := int64(0) + if req.Seed != nil { + seed = *req.Seed + } + + generated, usedSeed, err := h.generator.Generate(questiongen.GenerateParams{ + Topic: topic.QuestionTopic, + Difficulty: difficulty.QuestionDifficulty, + Count: req.Count, + Seed: seed, + }) + if err != nil { + return respond.Error(c, fiber.StatusBadRequest, "generation_failed", err.Error()) + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + source := shared.NullableText(req.Source) + if !source.Valid { + defaultSource := "rng_generated" + source = shared.NullableText(&defaultSource) + } + + responses := make([]GeneratedQuestionResponse, 0, len(generated)) + for index, item := range generated { + title := strings.TrimSpace(item.Title) + if title == "" { + title = fmt.Sprintf("%s %s %d", questionTopicLabel(topic.QuestionTopic), strings.Title(string(difficulty.QuestionDifficulty)), index+1) + } + + question, err := h.queries.CreateQuestion(ctx, sqlc.CreateQuestionParams{ + AuthorTeacherID: teacherID, + Title: title, + Prompt: strings.TrimSpace(item.Prompt), + Topic: topic, + Subject: shared.NullableText(subject), + Difficulty: difficulty, + Source: source, + CorrectAnswer: shared.NullableText(stringPointer(item.CorrectAnswer)), + Status: status, + }) + if err != nil { + return respond.DatabaseError(c, err) + } + + for _, tagName := range item.Tags { + tag, err := h.queries.CreateTag(ctx, tagName) + if err != nil { + return respond.DatabaseError(c, err) + } + if err := h.queries.AttachTagToQuestion(ctx, sqlc.AttachTagToQuestionParams{QuestionID: question.ID, TagID: tag.ID}); err != nil { + return respond.DatabaseError(c, err) + } + } + + responses = append(responses, GeneratedQuestionResponse{ + Question: mapQuestion(question), + Tags: item.Tags, + WorkedSolution: item.WorkedSolution, + }) + } + + return c.Status(fiber.StatusCreated).JSON(GenerateQuestionsResponse{ + Seed: usedSeed, + Data: responses, + Count: len(responses), + }) +} + +func (h *Handler) CreateTag(c *fiber.Ctx) error { + var req createTagRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + if strings.TrimSpace(req.Name) == "" { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "name is required") + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + tag, err := h.queries.CreateTag(ctx, strings.TrimSpace(req.Name)) + if err != nil { + return respond.DatabaseError(c, err) + } + + return c.Status(fiber.StatusCreated).JSON(mapTag(tag)) +} + +func (h *Handler) AttachTagToQuestion(c *fiber.Ctx) error { + questionID, err := params.Int64PathParam(c, "questionId") + if err != nil { + return err + } + + var req attachTagToQuestionRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + if req.TagID == 0 { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "tag_id is required") + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + err = h.queries.AttachTagToQuestion(ctx, sqlc.AttachTagToQuestionParams{ + QuestionID: questionID, + TagID: req.TagID, + }) + if err != nil { + return respond.DatabaseError(c, err) + } + + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "status": "ok", + "question_id": questionID, + "tag_id": req.TagID, + }) +} + +func mapQuestion(question sqlc.Question) QuestionResponse { + return QuestionResponse{ + ID: question.ID, + AuthorTeacherID: question.AuthorTeacherID, + Title: question.Title, + Prompt: question.Prompt, + Topic: questionTopicPointer(question.Topic), + Subject: shared.TextPointer(question.Subject), + Difficulty: questionDifficultyPointer(question.Difficulty), + Source: shared.TextPointer(question.Source), + CorrectAnswer: shared.TextPointer(question.CorrectAnswer), + Status: string(question.Status), + CreatedAt: shared.TimePointer(question.CreatedAt), + UpdatedAt: shared.TimePointer(question.UpdatedAt), + } +} + +func mapTag(tag sqlc.Tag) TagResponse { + return TagResponse{ + ID: tag.ID, + Name: tag.Name, + CreatedAt: shared.TimePointer(tag.CreatedAt), + } +} + +func parseQuestionTopic(rawTopic, rawSubject *string) (sqlc.NullQuestionTopic, *string, error) { + topicValue := strings.TrimSpace(firstNonEmpty(rawTopic, rawSubject)) + if topicValue == "" { + return sqlc.NullQuestionTopic{}, rawSubject, nil + } + + normalizedTopic, ok := normalizeQuestionTopic(topicValue) + if !ok { + return sqlc.NullQuestionTopic{}, nil, errors.New("topic must match the supported seeded subjects") + } + + subjectLabel := questionTopicLabel(sqlc.QuestionTopic(normalizedTopic)) + return sqlc.NullQuestionTopic{QuestionTopic: sqlc.QuestionTopic(normalizedTopic), Valid: true}, &subjectLabel, nil +} + +func parseQuestionDifficulty(rawDifficulty *string) (sqlc.NullQuestionDifficulty, error) { + value := strings.TrimSpace(firstNonEmpty(rawDifficulty)) + if value == "" { + return sqlc.NullQuestionDifficulty{}, nil + } + + switch strings.ToLower(value) { + case "easy": + return sqlc.NullQuestionDifficulty{QuestionDifficulty: sqlc.QuestionDifficultyEasy, Valid: true}, nil + case "medium": + return sqlc.NullQuestionDifficulty{QuestionDifficulty: sqlc.QuestionDifficultyMedium, Valid: true}, nil + case "hard": + return sqlc.NullQuestionDifficulty{QuestionDifficulty: sqlc.QuestionDifficultyHard, Valid: true}, nil + default: + return sqlc.NullQuestionDifficulty{}, errors.New("difficulty must be easy, medium, or hard") + } +} + +func normalizeQuestionTopic(value string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "place value", "place_value": + return string(sqlc.QuestionTopicPlaceValue), true + case "arithmetic": + return string(sqlc.QuestionTopicArithmetic), true + case "negative numbers", "negative_numbers": + return string(sqlc.QuestionTopicNegativeNumbers), true + case "bidmas": + return string(sqlc.QuestionTopicBidmas), true + case "fractions": + return string(sqlc.QuestionTopicFractions), true + case "algebra": + return string(sqlc.QuestionTopicAlgebra), true + case "geometry": + return string(sqlc.QuestionTopicGeometry), true + case "data": + return string(sqlc.QuestionTopicData), true + default: + return "", false + } +} + +func questionTopicLabel(topic sqlc.QuestionTopic) string { + switch topic { + case sqlc.QuestionTopicPlaceValue: + return "Place Value" + case sqlc.QuestionTopicArithmetic: + return "Arithmetic" + case sqlc.QuestionTopicNegativeNumbers: + return "Negative Numbers" + case sqlc.QuestionTopicBidmas: + return "BIDMAS" + case sqlc.QuestionTopicFractions: + return "Fractions" + case sqlc.QuestionTopicAlgebra: + return "Algebra" + case sqlc.QuestionTopicGeometry: + return "Geometry" + case sqlc.QuestionTopicData: + return "Data" + default: + return "" + } +} + +func questionTopicPointer(topic sqlc.NullQuestionTopic) *string { + if !topic.Valid { + return nil + } + label := string(topic.QuestionTopic) + return &label +} + +func questionDifficultyPointer(difficulty sqlc.NullQuestionDifficulty) *string { + if !difficulty.Valid { + return nil + } + value := string(difficulty.QuestionDifficulty) + return &value +} + +func firstNonEmpty(values ...*string) string { + for _, value := range values { + if value == nil { + continue + } + trimmed := strings.TrimSpace(*value) + if trimmed != "" { + return trimmed + } + } + return "" +} + +func stringPointer(value string) *string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + return &trimmed +} diff --git a/Backend/internal/handlers/api/questions/handler_test.go b/Backend/internal/handlers/api/questions/handler_test.go new file mode 100644 index 0000000..a24b512 --- /dev/null +++ b/Backend/internal/handlers/api/questions/handler_test.go @@ -0,0 +1,140 @@ +package questions + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "boostai-backend/internal/http/respond" + "boostai-backend/internal/questiongen" + "boostai-backend/internal/sqlc" + + "github.com/gofiber/fiber/v2" +) + +func TestGenerateQuestionsReturnsGeneratorUnavailable(t *testing.T) { + t.Parallel() + + handler := NewHandler(nil, nil) + status, body := performGenerateRequest(t, handler, map[string]any{ + "topic": "fractions", + "difficulty": "easy", + "count": 1, + }, true) + + if status != fiber.StatusServiceUnavailable { + t.Fatalf("expected status %d, got %d", fiber.StatusServiceUnavailable, status) + } + if body.Error != "generator_unavailable" { + t.Fatalf("expected generator_unavailable error, got %#v", body) + } +} + +func TestGenerateQuestionsRequiresTeacherAuthentication(t *testing.T) { + t.Parallel() + + handler := NewHandler(nil, questiongen.NewService()) + status, body := performGenerateRequest(t, handler, map[string]any{ + "topic": "fractions", + "difficulty": "easy", + "count": 1, + }, false) + + if status != fiber.StatusUnauthorized { + t.Fatalf("expected status %d, got %d", fiber.StatusUnauthorized, status) + } + if body.Error != "unauthorized" { + t.Fatalf("expected unauthorized error, got %#v", body) + } +} + +func TestGenerateQuestionsRejectsZeroCount(t *testing.T) { + t.Parallel() + + handler := NewHandler(nil, questiongen.NewService()) + status, body := performGenerateRequest(t, handler, map[string]any{ + "topic": "fractions", + "difficulty": "easy", + "count": 0, + }, true) + + if status != fiber.StatusBadRequest { + t.Fatalf("expected status %d, got %d", fiber.StatusBadRequest, status) + } + if body.Message != "count must be between 1 and 25" { + t.Fatalf("expected count validation message, got %#v", body) + } +} + +func TestGenerateQuestionsRejectsInvalidStatus(t *testing.T) { + t.Parallel() + + handler := NewHandler(nil, questiongen.NewService()) + status, body := performGenerateRequest(t, handler, map[string]any{ + "topic": "fractions", + "difficulty": "easy", + "count": 1, + "status": "invalid", + }, true) + + if status != fiber.StatusBadRequest { + t.Fatalf("expected status %d, got %d", fiber.StatusBadRequest, status) + } + if body.Message != "status must be draft, published, or archived" { + t.Fatalf("expected invalid status message, got %#v", body) + } +} + +func TestGenerateQuestionsRejectsInvalidTopic(t *testing.T) { + t.Parallel() + + handler := NewHandler(nil, questiongen.NewService()) + status, body := performGenerateRequest(t, handler, map[string]any{ + "topic": "not_a_topic", + "difficulty": "easy", + "count": 1, + }, true) + + if status != fiber.StatusBadRequest { + t.Fatalf("expected status %d, got %d", fiber.StatusBadRequest, status) + } + if body.Error != "invalid_request" { + t.Fatalf("expected invalid_request error, got %#v", body) + } +} + +func performGenerateRequest(t *testing.T, handler *Handler, payload map[string]any, authenticated bool) (int, respond.ErrorBody) { + t.Helper() + + app := fiber.New() + app.Post("/questions/generate", func(c *fiber.Ctx) error { + if authenticated { + c.Locals("auth.user_id", int64(42)) + c.Locals("auth.role", sqlc.UserRoleTeacher) + } + return handler.GenerateQuestions(c) + }) + + bodyBytes, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/questions/generate", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test returned error: %v", err) + } + defer resp.Body.Close() + + var errorBody respond.ErrorBody + if err := json.NewDecoder(resp.Body).Decode(&errorBody); err != nil { + t.Fatalf("decode error response: %v", err) + } + + return resp.StatusCode, errorBody +} diff --git a/Backend/internal/handlers/api/questions/routes.go b/Backend/internal/handlers/api/questions/routes.go new file mode 100644 index 0000000..111c297 --- /dev/null +++ b/Backend/internal/handlers/api/questions/routes.go @@ -0,0 +1,17 @@ +package questions + +import ( + authmw "boostai-backend/internal/middleware" + + "github.com/gofiber/fiber/v2" +) + +func RegisterRoutes(app fiber.Router, auth *authmw.AuthMiddleware, h *Handler) { + app.Get("/teachers/:teacherId/questions", auth.RequireTeacherSelf("teacherId"), h.ListQuestionsByTeacher) + app.Get("/questions/:questionId", h.GetQuestionByID) + app.Get("/tags", h.ListTags) + app.Post("/questions", auth.RequireTeacher(), h.CreateQuestion) + app.Post("/questions/generate", auth.RequireTeacher(), h.GenerateQuestions) + app.Post("/tags", auth.RequireTeacher(), h.CreateTag) + app.Post("/questions/:questionId/tags", auth.RequireTeacher(), h.AttachTagToQuestion) +} diff --git a/Backend/internal/handlers/api/routes.go b/Backend/internal/handlers/api/routes.go new file mode 100644 index 0000000..6293382 --- /dev/null +++ b/Backend/internal/handlers/api/routes.go @@ -0,0 +1,22 @@ +package api + +import ( + "boostai-backend/internal/handlers/api/answers" + "boostai-backend/internal/handlers/api/assignments" + "boostai-backend/internal/handlers/api/classrooms" + "boostai-backend/internal/handlers/api/messages" + "boostai-backend/internal/handlers/api/questions" + "boostai-backend/internal/handlers/api/users" + authmw "boostai-backend/internal/middleware" + + "github.com/gofiber/fiber/v2" +) + +func (h *Handler) Register(app fiber.Router, auth *authmw.AuthMiddleware) { + users.RegisterRoutes(app, auth, h.users) + classrooms.RegisterRoutes(app, auth, h.classrooms) + messages.RegisterRoutes(app, auth, h.messages) + questions.RegisterRoutes(app, auth, h.questions) + assignments.RegisterRoutes(app, auth, h.assignments) + answers.RegisterRoutes(app, auth, h.answers) +} diff --git a/Backend/internal/handlers/api/shared/shared.go b/Backend/internal/handlers/api/shared/shared.go new file mode 100644 index 0000000..64d0292 --- /dev/null +++ b/Backend/internal/handlers/api/shared/shared.go @@ -0,0 +1,159 @@ +// Path: Backend/internal/handlers/api/shared/shared.go + +package shared + +import ( + "boostai-backend/internal/sqlc" + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "golang.org/x/crypto/bcrypt" +) + +const QueryTimeout = 5 * time.Second + +type ListResponse[T any] struct { + Data []T `json:"data"` +} + +func WithTimeout() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), QueryTimeout) +} + +func IsValidAnswerStatus(status string) bool { + switch sqlc.AnswerStatus(strings.TrimSpace(status)) { + case sqlc.AnswerStatusNotStarted, + sqlc.AnswerStatusInProgress, + sqlc.AnswerStatusSubmitted, + sqlc.AnswerStatusReviewed: + return true + default: + return false + } +} + +func NullableText(value *string) pgtype.Text { + if value == nil { + return pgtype.Text{} + } + + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return pgtype.Text{} + } + + return pgtype.Text{String: trimmed, Valid: true} +} + +func MaybeHashPassword(value *string) (pgtype.Text, error) { + if value == nil { + return pgtype.Text{}, nil + } + + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return pgtype.Text{}, nil + } + + if len(trimmed) < 8 { + return pgtype.Text{}, errors.New("password must be at least 8 characters") + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(trimmed), bcrypt.DefaultCost) + if err != nil { + return pgtype.Text{}, err + } + + return pgtype.Text{String: string(hashedPassword), Valid: true}, nil +} + +func NullableTime(value *time.Time) pgtype.Timestamptz { + if value == nil { + return pgtype.Timestamptz{} + } + + return pgtype.Timestamptz{Time: value.UTC(), Valid: true} +} + +func NullableBool(value *bool) pgtype.Bool { + if value == nil { + return pgtype.Bool{} + } + + return pgtype.Bool{Bool: *value, Valid: true} +} + +func TextPointer(value pgtype.Text) *string { + if !value.Valid { + return nil + } + + text := value.String + return &text +} + +func TextValue(value pgtype.Text) string { + if !value.Valid { + return "" + } + + return value.String +} + +func TimePointer(value pgtype.Timestamptz) *time.Time { + if !value.Valid { + return nil + } + + timestamp := value.Time.UTC() + return ×tamp +} + +func Int64Pointer(value pgtype.Int8) *int64 { + if !value.Valid { + return nil + } + + v := value.Int64 + return &v +} + +func BoolPointer(value pgtype.Bool) *bool { + if !value.Valid { + return nil + } + + v := value.Bool + return &v +} + +func NullableFloat64AsNumeric(value *float64) (pgtype.Numeric, error) { + if value == nil { + return pgtype.Numeric{}, nil + } + + numeric := pgtype.Numeric{} + if err := numeric.ScanScientific(fmt.Sprintf("%f", *value)); err != nil { + return pgtype.Numeric{}, err + } + + return numeric, nil +} + +func NumericPointer(value pgtype.Numeric) *float64 { + if !value.Valid { + return nil + } + + floatValue, err := value.Float64Value() + if err != nil || !floatValue.Valid { + return nil + } + + v := floatValue.Float64 + return &v +} diff --git a/Backend/internal/handlers/api/users/handler.go b/Backend/internal/handlers/api/users/handler.go new file mode 100644 index 0000000..15210f2 --- /dev/null +++ b/Backend/internal/handlers/api/users/handler.go @@ -0,0 +1,133 @@ +// Path: Backend/internal/handlers/api/users/handler.go + +package users + +import ( + "boostai-backend/internal/handlers/api/shared" + "boostai-backend/internal/http/params" + "boostai-backend/internal/http/respond" + "boostai-backend/internal/sqlc" + "errors" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +type Handler struct { + queries *sqlc.Queries +} + +type UserResponse struct { + ID int64 `json:"id"` + Email string `json:"email"` + Role string `json:"role"` + FullName string `json:"full_name"` + IsActive bool `json:"is_active"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + PasswordHash *string `json:"password_hash,omitempty"` +} + +type createUserRequest struct { + Email string `json:"email"` + Password *string `json:"password"` + Role string `json:"role"` + FullName string `json:"full_name"` +} + +func NewHandler(queries *sqlc.Queries) *Handler { + return &Handler{queries: queries} +} + +func (h *Handler) ListUsersByRole(c *fiber.Ctx) error { + role := strings.TrimSpace(c.Query("role")) + if role == "" { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Query parameter 'role' is required") + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + users, err := h.queries.ListUsersByRole(ctx, sqlc.UserRole(role)) + if err != nil { + return respond.DatabaseError(c, err) + } + + items := make([]UserResponse, 0, len(users)) + for _, user := range users { + items = append(items, mapUser(user, false)) + } + + return c.JSON(shared.ListResponse[UserResponse]{Data: items}) +} + +func (h *Handler) GetUserByID(c *fiber.Ctx) error { + id, err := params.Int64PathParam(c, "id") + if err != nil { + return err + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + user, err := h.queries.GetUserByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusNotFound, "not_found", "User not found") + } + return respond.DatabaseError(c, err) + } + + return c.JSON(mapUser(user, false)) +} + +func (h *Handler) CreateUser(c *fiber.Ctx) error { + var req createUserRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + if strings.TrimSpace(req.Email) == "" || strings.TrimSpace(req.FullName) == "" || strings.TrimSpace(req.Role) == "" { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "email, full_name, and role are required") + } + + passwordHash, err := shared.MaybeHashPassword(req.Password) + if err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", err.Error()) + } + + ctx, cancel := shared.WithTimeout() + defer cancel() + + user, err := h.queries.CreateUser(ctx, sqlc.CreateUserParams{ + Email: strings.TrimSpace(req.Email), + PasswordHash: passwordHash, + Role: sqlc.UserRole(strings.TrimSpace(req.Role)), + FullName: strings.TrimSpace(req.FullName), + }) + if err != nil { + return respond.DatabaseError(c, err) + } + + return c.Status(fiber.StatusCreated).JSON(mapUser(user, false)) +} + +func mapUser(user sqlc.User, includePasswordHash bool) UserResponse { + response := UserResponse{ + ID: user.ID, + Email: user.Email, + Role: string(user.Role), + FullName: user.FullName, + IsActive: user.IsActive, + CreatedAt: shared.TimePointer(user.CreatedAt), + UpdatedAt: shared.TimePointer(user.UpdatedAt), + } + + if includePasswordHash { + response.PasswordHash = shared.TextPointer(user.PasswordHash) + } + + return response +} diff --git a/Backend/internal/handlers/api/users/routes.go b/Backend/internal/handlers/api/users/routes.go new file mode 100644 index 0000000..ac9b50d --- /dev/null +++ b/Backend/internal/handlers/api/users/routes.go @@ -0,0 +1,13 @@ +package users + +import ( + authmw "boostai-backend/internal/middleware" + + "github.com/gofiber/fiber/v2" +) + +func RegisterRoutes(app fiber.Router, auth *authmw.AuthMiddleware, h *Handler) { + app.Get("/users", auth.RequireTeacher(), h.ListUsersByRole) + app.Get("/users/:id", h.GetUserByID) + app.Post("/users", auth.RequireTeacher(), h.CreateUser) +} diff --git a/Backend/internal/handlers/web/auth/auth.go b/Backend/internal/handlers/web/auth/auth.go new file mode 100644 index 0000000..6929914 --- /dev/null +++ b/Backend/internal/handlers/web/auth/auth.go @@ -0,0 +1,384 @@ +// Path: Backend/internal/handlers/auth/auth.go + +package auth + +import ( + "context" + "errors" + "strings" + "time" + + "boostai-backend/internal/config" + "boostai-backend/internal/database" + "boostai-backend/internal/http/respond" + authmw "boostai-backend/internal/middleware" + "boostai-backend/internal/sqlc" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "golang.org/x/crypto/bcrypt" +) + +const authQueryTimeout = 5 * time.Second + +type Handler struct { + db *database.DB + queries *sqlc.Queries + cfg *config.Config + auth *authmw.AuthMiddleware +} + +type authProfileResponse struct { + PreferredName *string `json:"preferred_name"` + ProfileIconURL *string `json:"profile_icon_url"` + Headline *string `json:"headline"` + Bio *string `json:"bio"` + Timezone *string `json:"timezone"` + Locale *string `json:"locale"` + GradeLevel *string `json:"grade_level"` + LearningGoal *string `json:"learning_goal"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` +} + +type authUserResponse struct { + ID int64 `json:"id"` + Email string `json:"email"` + Role string `json:"role"` + FullName string `json:"full_name"` + IsActive bool `json:"is_active"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + Profile authProfileResponse `json:"profile"` +} + +type authResponse struct { + User authUserResponse `json:"user"` +} + +type registerRequest struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + Password string `json:"password"` + Role string `json:"role"` +} + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` + RememberMe bool `json:"remember_me"` +} + +type updateProfileRequest struct { + FullName *string `json:"full_name"` + PreferredName *string `json:"preferred_name"` + ProfileIconURL *string `json:"profile_icon_url"` + Headline *string `json:"headline"` + Bio *string `json:"bio"` + Timezone *string `json:"timezone"` + Locale *string `json:"locale"` + GradeLevel *string `json:"grade_level"` + LearningGoal *string `json:"learning_goal"` +} + +func NewHandler(cfg *config.Config, db *database.DB, auth *authmw.AuthMiddleware) *Handler { + return &Handler{db: db, queries: sqlc.New(db.Pool), cfg: cfg, auth: auth} +} + +func (h *Handler) RegisterUser(c *fiber.Ctx) error { + var req registerRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + fullName := strings.TrimSpace(strings.TrimSpace(req.FirstName) + " " + strings.TrimSpace(req.LastName)) + if fullName == "" || strings.TrimSpace(req.Email) == "" || strings.TrimSpace(req.Password) == "" { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "first_name, last_name, email, and password are required") + } + + role := sqlc.UserRoleStudent + if strings.TrimSpace(req.Role) != "" { + role = sqlc.UserRole(strings.TrimSpace(req.Role)) + } + if role != sqlc.UserRoleStudent && role != sqlc.UserRoleTeacher { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "role must be student or teacher") + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return respond.Error(c, fiber.StatusInternalServerError, "auth_error", "Unable to secure password") + } + + ctx, cancel := withTimeout() + defer cancel() + + user, err := h.queries.CreateUser(ctx, sqlc.CreateUserParams{ + Email: strings.TrimSpace(strings.ToLower(req.Email)), + PasswordHash: pgtype.Text{String: string(hashedPassword), Valid: true}, + Role: role, + FullName: fullName, + }) + if err != nil { + return respond.DatabaseError(c, err) + } + + if err := h.setSessionCookie(c, user, false); err != nil { + return respond.Error(c, fiber.StatusInternalServerError, "auth_error", "Unable to create session") + } + + authUser, err := h.queries.GetAuthUserByID(ctx, user.ID) + if err != nil { + return respond.DatabaseError(c, err) + } + + return c.Status(fiber.StatusCreated).JSON(authResponse{User: mapAuthUserByID(authUser)}) +} + +func (h *Handler) Login(c *fiber.Ctx) error { + var req loginRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + if strings.TrimSpace(req.Email) == "" || strings.TrimSpace(req.Password) == "" { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "email and password are required") + } + + ctx, cancel := withTimeout() + defer cancel() + + user, err := h.queries.GetUserByEmail(ctx, strings.TrimSpace(strings.ToLower(req.Email))) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusUnauthorized, "invalid_credentials", "Invalid email or password") + } + return respond.DatabaseError(c, err) + } + + if !user.IsActive || !user.PasswordHash.Valid { + return respond.Error(c, fiber.StatusUnauthorized, "invalid_credentials", "Invalid email or password") + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash.String), []byte(req.Password)); err != nil { + return respond.Error(c, fiber.StatusUnauthorized, "invalid_credentials", "Invalid email or password") + } + + if err := h.setSessionCookie(c, user, req.RememberMe); err != nil { + return respond.Error(c, fiber.StatusInternalServerError, "auth_error", "Unable to create session") + } + + authUser, err := h.queries.GetAuthUserByID(ctx, user.ID) + if err != nil { + return respond.DatabaseError(c, err) + } + + return c.JSON(authResponse{User: mapAuthUserByID(authUser)}) +} + +func (h *Handler) Me(c *fiber.Ctx) error { + userID := authmw.CurrentUserID(c) + if userID == 0 { + return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "Authentication required") + } + + ctx, cancel := withTimeout() + defer cancel() + + user, err := h.queries.GetAuthUserByID(ctx, userID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "User not found") + } + return respond.DatabaseError(c, err) + } + + return c.JSON(authResponse{User: mapAuthUserByID(user)}) +} + +func (h *Handler) UpdateMe(c *fiber.Ctx) error { + userID := authmw.CurrentUserID(c) + if userID == 0 { + return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "Authentication required") + } + + var req updateProfileRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + ctx, cancel := withTimeout() + defer cancel() + + tx, err := h.db.Pool.Begin(ctx) + if err != nil { + return respond.DatabaseError(c, err) + } + defer func() { + _ = tx.Rollback(ctx) + }() + + queries := h.queries.WithTx(tx) + current, err := queries.GetAuthUserByID(ctx, userID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "User not found") + } + return respond.DatabaseError(c, err) + } + + fullName, err := mergeRequiredString(current.UserFullName, req.FullName, "full_name") + if err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", err.Error()) + } + + if fullName != current.UserFullName { + if _, err := queries.UpdateUserFullName(ctx, sqlc.UpdateUserFullNameParams{ID: userID, FullName: fullName}); err != nil { + return respond.DatabaseError(c, err) + } + } + + if _, err := queries.UpsertUserProfile(ctx, sqlc.UpsertUserProfileParams{ + UserID: userID, + PreferredName: mergeNullableText(current.PreferredName, req.PreferredName), + ProfileIconUrl: mergeNullableText(current.ProfileIconUrl, req.ProfileIconURL), + Headline: mergeNullableText(current.Headline, req.Headline), + Bio: mergeNullableText(current.Bio, req.Bio), + Timezone: mergeNullableText(current.Timezone, req.Timezone), + Locale: mergeNullableText(current.Locale, req.Locale), + GradeLevel: mergeNullableText(current.GradeLevel, req.GradeLevel), + LearningGoal: mergeNullableText(current.LearningGoal, req.LearningGoal), + }); err != nil { + return respond.DatabaseError(c, err) + } + + updated, err := queries.GetAuthUserByID(ctx, userID) + if err != nil { + return respond.DatabaseError(c, err) + } + + if err := tx.Commit(ctx); err != nil { + return respond.DatabaseError(c, err) + } + + return c.JSON(authResponse{User: mapAuthUserByID(updated)}) +} + +func (h *Handler) Logout(c *fiber.Ctx) error { + h.clearSessionCookie(c) + return c.JSON(fiber.Map{"status": "ok"}) +} + +func (h *Handler) setSessionCookie(c *fiber.Ctx, user sqlc.User, rememberMe bool) error { + ttl := 24 * time.Hour + if rememberMe { + ttl = 30 * 24 * time.Hour + } + + token, err := h.auth.CreateToken(user.ID, user.Role, user.Email, ttl) + if err != nil { + return err + } + + c.Cookie(&fiber.Cookie{ + Name: h.cfg.SessionCookie, + Value: token, + HTTPOnly: true, + Secure: h.cfg.IsProduction(), + SameSite: fiber.CookieSameSiteLaxMode, + Path: "/", + Expires: time.Now().UTC().Add(ttl), + }) + + return nil +} + +func (h *Handler) clearSessionCookie(c *fiber.Ctx) { + c.Cookie(&fiber.Cookie{ + Name: h.cfg.SessionCookie, + Value: "", + HTTPOnly: true, + Secure: h.cfg.IsProduction(), + SameSite: fiber.CookieSameSiteLaxMode, + Path: "/", + Expires: time.Unix(0, 0), + MaxAge: -1, + }) +} + +func mapAuthUserByID(user sqlc.GetAuthUserByIDRow) authUserResponse { + return authUserResponse{ + ID: user.UserID, + Email: user.UserEmail, + Role: string(user.UserRole), + FullName: user.UserFullName, + IsActive: user.UserIsActive, + CreatedAt: timePointer(user.UserCreatedAt), + UpdatedAt: timePointer(user.UserUpdatedAt), + Profile: mapAuthProfile(user.PreferredName, user.ProfileIconUrl, user.Headline, user.Bio, user.Timezone, user.Locale, user.GradeLevel, user.LearningGoal, user.ProfileCreatedAt, user.ProfileUpdatedAt), + } +} + +func mapAuthProfile(preferredName, profileIconURL, headline, bio, timezone, locale, gradeLevel, learningGoal pgtype.Text, createdAt, updatedAt pgtype.Timestamptz) authProfileResponse { + return authProfileResponse{ + PreferredName: textPointer(preferredName), + ProfileIconURL: textPointer(profileIconURL), + Headline: textPointer(headline), + Bio: textPointer(bio), + Timezone: textPointer(timezone), + Locale: textPointer(locale), + GradeLevel: textPointer(gradeLevel), + LearningGoal: textPointer(learningGoal), + CreatedAt: timePointer(createdAt), + UpdatedAt: timePointer(updatedAt), + } +} + +func withTimeout() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), authQueryTimeout) +} + +func mergeRequiredString(current string, input *string, fieldName string) (string, error) { + if input == nil { + return current, nil + } + + value := strings.TrimSpace(*input) + if value == "" { + return "", errors.New(fieldName + " cannot be empty") + } + + return value, nil +} + +func mergeNullableText(current pgtype.Text, input *string) pgtype.Text { + if input == nil { + return current + } + + value := strings.TrimSpace(*input) + if value == "" { + return pgtype.Text{} + } + + return pgtype.Text{String: value, Valid: true} +} + +func textPointer(value pgtype.Text) *string { + if !value.Valid { + return nil + } + + text := value.String + return &text +} + +func timePointer(value pgtype.Timestamptz) *time.Time { + if !value.Valid { + return nil + } + + timestamp := value.Time.UTC() + return ×tamp +} diff --git a/Backend/internal/handlers/web/health/health.go b/Backend/internal/handlers/web/health/health.go new file mode 100644 index 0000000..43f9c45 --- /dev/null +++ b/Backend/internal/handlers/web/health/health.go @@ -0,0 +1,46 @@ +// Path: Backend/internal/handlers/health/health.go + +package health + +import ( + "context" + "time" + + "boostai-backend/internal/database" + + "github.com/gofiber/fiber/v2" +) + +type Handler struct { + environment string + db *database.DB +} + +func NewHandler(environment string, db *database.DB) *Handler { + return &Handler{environment: environment, db: db} +} + +func (h *Handler) Check(c *fiber.Ctx) error { + status := "healthy" + databaseStatus := "up" + httpStatus := fiber.StatusOK + + if h.db != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if err := h.db.Health(ctx); err != nil { + status = "unhealthy" + databaseStatus = "down" + httpStatus = fiber.StatusServiceUnavailable + } + } + + return c.Status(httpStatus).JSON(fiber.Map{ + "status": status, + "service": "boostai-backend", + "environment": h.environment, + "database": databaseStatus, + "timestamp": time.Now().UTC().Format(time.RFC3339), + }) +} diff --git a/Backend/internal/handlers/web/root/root.go b/Backend/internal/handlers/web/root/root.go new file mode 100644 index 0000000..a11316b --- /dev/null +++ b/Backend/internal/handlers/web/root/root.go @@ -0,0 +1,16 @@ +package root + +import "github.com/gofiber/fiber/v2" + +type Handler struct{} + +func NewHandler() *Handler { + return &Handler{} +} + +func (h *Handler) Index(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "name": "BoostAI Backend", + "status": "ok", + }) +} diff --git a/Backend/internal/http/params/params.go b/Backend/internal/http/params/params.go new file mode 100644 index 0000000..de9063d --- /dev/null +++ b/Backend/internal/http/params/params.go @@ -0,0 +1,21 @@ +// Path: Backend/internal/http/params/params.go + +package params + +import ( + "boostai-backend/internal/http/respond" + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" +) + +func Int64PathParam(c *fiber.Ctx, name string) (int64, error) { + value := strings.TrimSpace(c.Params(name)) + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed <= 0 { + return 0, respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Invalid path parameter: "+name) + } + + return parsed, nil +} diff --git a/Backend/internal/http/respond/respond.go b/Backend/internal/http/respond/respond.go new file mode 100644 index 0000000..77247c8 --- /dev/null +++ b/Backend/internal/http/respond/respond.go @@ -0,0 +1,18 @@ +// Path: Backend/internal/http/respond/respond.go + +package respond + +import "github.com/gofiber/fiber/v2" + +type ErrorBody struct { + Error string `json:"error"` + Message string `json:"message"` +} + +func Error(c *fiber.Ctx, status int, code string, message string) error { + return c.Status(status).JSON(ErrorBody{Error: code, Message: message}) +} + +func DatabaseError(c *fiber.Ctx, err error) error { + return Error(c, fiber.StatusInternalServerError, "database_error", err.Error()) +} diff --git a/Backend/internal/middleware/auth.go b/Backend/internal/middleware/auth.go new file mode 100644 index 0000000..f118cd1 --- /dev/null +++ b/Backend/internal/middleware/auth.go @@ -0,0 +1,193 @@ +// Path: Backend/internal/middleware/auth.go + +package middleware + +import ( + "errors" + "strconv" + "strings" + "time" + + "boostai-backend/internal/config" + "boostai-backend/internal/sqlc" + + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v5" +) + +const DefaultTokenTTL = 7 * 24 * time.Hour + +type AuthClaims struct { + UserID int64 `json:"user_id"` + Role sqlc.UserRole `json:"role"` + Email string `json:"email"` + jwt.RegisteredClaims +} + +type AuthMiddleware struct { + cfg *config.Config +} + +func NewAuthMiddleware(cfg *config.Config) *AuthMiddleware { + return &AuthMiddleware{cfg: cfg} +} + +func (m *AuthMiddleware) CreateToken(userID int64, role sqlc.UserRole, email string, ttl time.Duration) (string, error) { + if ttl <= 0 { + ttl = DefaultTokenTTL + } + + now := time.Now().UTC() + claims := AuthClaims{ + UserID: userID, + Role: role, + Email: email, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: email, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(m.cfg.JWTSecret)) +} + +func (m *AuthMiddleware) RequireAuth() fiber.Handler { + return func(c *fiber.Ctx) error { + claims, err := m.parseClaims(c) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "unauthorized", + "message": "Authentication required", + }) + } + + c.Locals("auth.user_id", claims.UserID) + c.Locals("auth.role", claims.Role) + c.Locals("auth.email", claims.Email) + return c.Next() + } +} + +func (m *AuthMiddleware) RequireTeacher() fiber.Handler { + return func(c *fiber.Ctx) error { + if CurrentUserRole(c) != sqlc.UserRoleTeacher { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "forbidden", + "message": "Teacher access required", + }) + } + + return c.Next() + } +} + +func (m *AuthMiddleware) RequireTeacherSelf(param string) fiber.Handler { + return func(c *fiber.Ctx) error { + if CurrentUserRole(c) != sqlc.UserRoleTeacher { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "forbidden", + "message": "Teacher access required", + }) + } + + paramID, err := parsePositiveParam(c, param) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "invalid_request", + "message": "Invalid path parameter: " + param, + }) + } + + if CurrentUserID(c) != paramID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "forbidden", + "message": "You can only access your own teacher resources", + }) + } + + return c.Next() + } +} + +func (m *AuthMiddleware) RequireStudentSelfOrTeacher(param string) fiber.Handler { + return func(c *fiber.Ctx) error { + paramID, err := parsePositiveParam(c, param) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "invalid_request", + "message": "Invalid path parameter: " + param, + }) + } + + if CurrentUserRole(c) == sqlc.UserRoleTeacher || CurrentUserID(c) == paramID { + return c.Next() + } + + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "forbidden", + "message": "You can only access your own student resources", + }) + } +} + +func CurrentUserID(c *fiber.Ctx) int64 { + value, ok := c.Locals("auth.user_id").(int64) + if !ok { + return 0 + } + + return value +} + +func CurrentUserRole(c *fiber.Ctx) sqlc.UserRole { + value, ok := c.Locals("auth.role").(sqlc.UserRole) + if !ok { + return "" + } + + return value +} + +func (m *AuthMiddleware) parseClaims(c *fiber.Ctx) (*AuthClaims, error) { + tokenValue := strings.TrimSpace(c.Cookies(m.cfg.SessionCookie)) + if tokenValue == "" { + authorization := strings.TrimSpace(c.Get("Authorization")) + if strings.HasPrefix(strings.ToLower(authorization), "bearer ") { + tokenValue = strings.TrimSpace(authorization[7:]) + } + } + + if tokenValue == "" { + return nil, errors.New("missing token") + } + + parsed, err := jwt.ParseWithClaims(tokenValue, &AuthClaims{}, func(token *jwt.Token) (any, error) { + if token.Method != jwt.SigningMethodHS256 { + return nil, errors.New("unexpected signing method") + } + + return []byte(m.cfg.JWTSecret), nil + }) + if err != nil { + return nil, err + } + + claims, ok := parsed.Claims.(*AuthClaims) + if !ok || !parsed.Valid { + return nil, errors.New("invalid token") + } + + return claims, nil +} + +func parsePositiveParam(c *fiber.Ctx, param string) (int64, error) { + value := strings.TrimSpace(c.Params(param)) + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed <= 0 { + return 0, errors.New("invalid param") + } + + return parsed, nil +} diff --git a/Backend/internal/questiongen/service.go b/Backend/internal/questiongen/service.go new file mode 100644 index 0000000..cbea381 --- /dev/null +++ b/Backend/internal/questiongen/service.go @@ -0,0 +1,634 @@ +package questiongen + +import ( + "fmt" + "math/rand" + "sort" + "strings" + "time" + + "boostai-backend/internal/sqlc" +) + +type Service struct{} + +type GenerateParams struct { + Topic sqlc.QuestionTopic + Difficulty sqlc.QuestionDifficulty + Count int + Seed int64 +} + +type GeneratedQuestion struct { + Title string + Prompt string + CorrectAnswer string + WorkedSolution []string + Tags []string +} + +func NewService() *Service { + return &Service{} +} + +func (s *Service) Generate(params GenerateParams) ([]GeneratedQuestion, int64, error) { + count := params.Count + if count <= 0 { + count = 1 + } + + seed := params.Seed + if seed == 0 { + seed = time.Now().UnixNano() + } + + rng := rand.New(rand.NewSource(seed)) + items := make([]GeneratedQuestion, 0, count) + for i := 0; i < count; i++ { + question, err := s.generateOne(rng, params.Topic, params.Difficulty) + if err != nil { + return nil, seed, err + } + items = append(items, question) + } + + return items, seed, nil +} + +func (s *Service) generateOne(rng *rand.Rand, topic sqlc.QuestionTopic, difficulty sqlc.QuestionDifficulty) (GeneratedQuestion, error) { + switch topic { + case sqlc.QuestionTopicPlaceValue: + return generatePlaceValueQuestion(rng, difficulty), nil + case sqlc.QuestionTopicArithmetic: + return generateArithmeticQuestion(rng, difficulty), nil + case sqlc.QuestionTopicNegativeNumbers: + return generateNegativeNumbersQuestion(rng, difficulty), nil + case sqlc.QuestionTopicBidmas: + return generateBidmasQuestion(rng, difficulty), nil + case sqlc.QuestionTopicFractions: + return generateFractionsQuestion(rng, difficulty), nil + case sqlc.QuestionTopicAlgebra: + return generateAlgebraQuestion(rng, difficulty), nil + case sqlc.QuestionTopicGeometry: + return generateGeometryQuestion(rng, difficulty), nil + case sqlc.QuestionTopicData: + return generateDataQuestion(rng, difficulty), nil + default: + return GeneratedQuestion{}, fmt.Errorf("unsupported topic: %s", topic) + } +} + +// Future word_problem work should not just bolt a `word_problem` tag onto an already-built +// abstract question. Each topic generator should eventually expose dedicated word-problem +// template families so the RNG chooses both the maths structure and a fitting real-world context +// together. That will keep prompts, answers, and worked steps consistent instead of doing a late +// text rewrite after the numbers are chosen. +func buildGeneratedTags(topic sqlc.QuestionTopic, difficulty sqlc.QuestionDifficulty, extra ...string) []string { + tags := []string{string(topic), string(difficulty), "rng_generated"} + tags = append(tags, extra...) + + unique := make(map[string]struct{}, len(tags)) + normalized := make([]string, 0, len(tags)) + for _, tag := range tags { + value := strings.ToLower(strings.TrimSpace(tag)) + if value == "" { + continue + } + if _, exists := unique[value]; exists { + continue + } + unique[value] = struct{}{} + normalized = append(normalized, value) + } + sort.Strings(normalized) + return normalized +} + +func generatePlaceValueQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion { + var digits, targetIndex int + switch difficulty { + case sqlc.QuestionDifficultyEasy: + digits = 2 + targetIndex = randomInt(rng, 0, 1) + case sqlc.QuestionDifficultyMedium: + digits = 3 + targetIndex = randomInt(rng, 0, 2) + default: + digits = randomInt(rng, 4, 5) + targetIndex = randomInt(rng, 1, digits-1) + } + + numberDigits := make([]int, digits) + numberDigits[0] = randomInt(rng, 1, 9) + for i := 1; i < digits; i++ { + numberDigits[i] = randomInt(rng, 0, 9) + } + number := digitsToInt(numberDigits) + digit := numberDigits[targetIndex] + placePower := digits - targetIndex - 1 + placeValue := digit + for i := 0; i < placePower; i++ { + placeValue *= 10 + } + + placeName := placeNameFromPower(placePower) + prompt := fmt.Sprintf("What is the value of the digit %d in %d?", digit, number) + return GeneratedQuestion{ + Title: fmt.Sprintf("%s Place Value", strings.Title(string(difficulty))), + Prompt: prompt, + CorrectAnswer: fmt.Sprintf("%d", placeValue), + WorkedSolution: []string{ + fmt.Sprintf("In %d, the digit %d is in the %s place.", number, digit, placeName), + fmt.Sprintf("So its value is %d.", placeValue), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicPlaceValue, difficulty, placeName), + } +} + +func generateArithmeticQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion { + if difficulty == sqlc.QuestionDifficultyEasy { + a := randomInt(rng, 1, 9) + b := randomInt(rng, 1, 9) + if rng.Intn(2) == 0 { + return GeneratedQuestion{ + Title: "Easy Addition", + Prompt: fmt.Sprintf("Calculate %d + %d.", a, b), + CorrectAnswer: fmt.Sprintf("%d", a+b), + WorkedSolution: []string{ + fmt.Sprintf("Add the ones: %d + %d = %d.", a, b, a+b), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicArithmetic, difficulty, "addition", "single_digit"), + } + } + + if a < b { + a, b = b, a + } + return GeneratedQuestion{ + Title: "Easy Subtraction", + Prompt: fmt.Sprintf("Calculate %d - %d.", a, b), + CorrectAnswer: fmt.Sprintf("%d", a-b), + WorkedSolution: []string{ + fmt.Sprintf("Subtract %d from %d.", b, a), + fmt.Sprintf("%d - %d = %d.", a, b, a-b), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicArithmetic, difficulty, "subtraction", "single_digit"), + } + } + + if difficulty == sqlc.QuestionDifficultyMedium { + if rng.Intn(2) == 0 { + a := randomInt(rng, 10, 99) + b := randomInt(rng, 10, 99) + return GeneratedQuestion{ + Title: "Medium Addition", + Prompt: fmt.Sprintf("Work out %d + %d.", a, b), + CorrectAnswer: fmt.Sprintf("%d", a+b), + WorkedSolution: []string{ + fmt.Sprintf("Add the numbers together: %d + %d = %d.", a, b, a+b), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicArithmetic, difficulty, "addition", "two_digit"), + } + } + + a := randomInt(rng, 2, 12) + b := randomInt(rng, 2, 12) + return GeneratedQuestion{ + Title: "Medium Multiplication", + Prompt: fmt.Sprintf("Calculate %d × %d.", a, b), + CorrectAnswer: fmt.Sprintf("%d", a*b), + WorkedSolution: []string{ + fmt.Sprintf("Use multiplication facts: %d × %d = %d.", a, b, a*b), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicArithmetic, difficulty, "multiplication", "times_tables"), + } + } + + if rng.Intn(2) == 0 { + divisor := randomInt(rng, 3, 12) + quotient := randomInt(rng, 4, 12) + dividend := divisor * quotient + return GeneratedQuestion{ + Title: "Hard Division", + Prompt: fmt.Sprintf("Calculate %d ÷ %d.", dividend, divisor), + CorrectAnswer: fmt.Sprintf("%d", quotient), + WorkedSolution: []string{ + fmt.Sprintf("Use the inverse of multiplication: %d × %d = %d.", divisor, quotient, dividend), + fmt.Sprintf("So %d ÷ %d = %d.", dividend, divisor, quotient), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicArithmetic, difficulty, "division", "inverse_operations"), + } + } + + a := randomInt(rng, 20, 99) + b := randomInt(rng, 11, 49) + return GeneratedQuestion{ + Title: "Hard Subtraction", + Prompt: fmt.Sprintf("Work out %d - %d.", a, b), + CorrectAnswer: fmt.Sprintf("%d", a-b), + WorkedSolution: []string{ + fmt.Sprintf("Subtract %d from %d carefully, using column subtraction if needed.", b, a), + fmt.Sprintf("%d - %d = %d.", a, b, a-b), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicArithmetic, difficulty, "subtraction", "column_method"), + } +} + +func generateNegativeNumbersQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion { + if difficulty == sqlc.QuestionDifficultyEasy { + a := randomInt(rng, -9, 9) + b := randomInt(rng, -9, 9) + result := a + b + return GeneratedQuestion{ + Title: "Easy Negative Numbers", + Prompt: fmt.Sprintf("Calculate %d + %d.", a, b), + CorrectAnswer: fmt.Sprintf("%d", result), + WorkedSolution: []string{ + fmt.Sprintf("Start at %d on the number line.", a), + fmt.Sprintf("Move %d steps to get %d.", b, result), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicNegativeNumbers, difficulty, "addition"), + } + } + + if difficulty == sqlc.QuestionDifficultyMedium { + a := randomInt(rng, -20, 20) + b := randomInt(rng, -20, 20) + result := a - b + return GeneratedQuestion{ + Title: "Medium Negative Numbers", + Prompt: fmt.Sprintf("Calculate %d - (%d).", a, b), + CorrectAnswer: fmt.Sprintf("%d", result), + WorkedSolution: []string{ + fmt.Sprintf("Subtracting %d is the same as adding %d.", b, -b), + fmt.Sprintf("So %d - (%d) = %d + %d = %d.", a, b, a, -b, result), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicNegativeNumbers, difficulty, "subtraction"), + } + } + + a := randomInt(rng, -30, 30) + b := randomInt(rng, -30, 30) + c := randomInt(rng, -15, 15) + result := a - b + c + prompt := fmt.Sprintf("Calculate %d - (%d) + %d.", a, b, c) + return GeneratedQuestion{ + Title: "Hard Negative Numbers", + Prompt: prompt, + CorrectAnswer: fmt.Sprintf("%d", result), + WorkedSolution: []string{ + fmt.Sprintf("First change subtraction of a negative: %d - (%d) = %d + %d.", a, b, a, -b), + fmt.Sprintf("Then add %d to get %d.", c, result), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicNegativeNumbers, difficulty, "multi_step"), + } +} + +func generateBidmasQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion { + if difficulty == sqlc.QuestionDifficultyEasy { + a := randomInt(rng, 1, 20) + b := randomInt(rng, 2, 9) + c := randomInt(rng, 2, 9) + result := a + b*c + return GeneratedQuestion{ + Title: "Easy BIDMAS", + Prompt: fmt.Sprintf("Work out %d + %d × %d.", a, b, c), + CorrectAnswer: fmt.Sprintf("%d", result), + WorkedSolution: []string{ + fmt.Sprintf("Do multiplication first: %d × %d = %d.", b, c, b*c), + fmt.Sprintf("Then add %d + %d = %d.", a, b*c, result), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicBidmas, difficulty, "order_of_operations"), + } + } + + if difficulty == sqlc.QuestionDifficultyMedium { + a := randomInt(rng, 2, 12) + b := randomInt(rng, 3, 12) + c := randomInt(rng, 2, 10) + result := (a + b) * c + return GeneratedQuestion{ + Title: "Medium BIDMAS", + Prompt: fmt.Sprintf("Work out (%d + %d) × %d.", a, b, c), + CorrectAnswer: fmt.Sprintf("%d", result), + WorkedSolution: []string{ + fmt.Sprintf("Work inside brackets first: %d + %d = %d.", a, b, a+b), + fmt.Sprintf("Then multiply %d × %d = %d.", a+b, c, result), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicBidmas, difficulty, "brackets"), + } + } + + a := randomInt(rng, 2, 12) + b := randomInt(rng, 2, 6) + c := randomInt(rng, 2, 12) + d := randomInt(rng, 2, 5) + left := a * b + right := c * d + result := left + right + return GeneratedQuestion{ + Title: "Hard BIDMAS", + Prompt: fmt.Sprintf("Work out %d × %d + %d × %d.", a, b, c, d), + CorrectAnswer: fmt.Sprintf("%d", result), + WorkedSolution: []string{ + fmt.Sprintf("Complete each multiplication first: %d × %d = %d and %d × %d = %d.", a, b, left, c, d, right), + fmt.Sprintf("Then add %d + %d = %d.", left, right, result), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicBidmas, difficulty, "multiple_operations"), + } +} + +func generateFractionsQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion { + if difficulty == sqlc.QuestionDifficultyEasy { + denominator := randomInt(rng, 2, 9) + numerator := randomInt(rng, 1, denominator-1) + maxMultiplier := 9 / denominator + if maxMultiplier < 1 { + maxMultiplier = 1 + } + multiplier := randomInt(rng, 1, maxMultiplier) + prompt := fmt.Sprintf("What is %d/%d of %d?", numerator, denominator, denominator*multiplier) + answer := numerator * multiplier + return GeneratedQuestion{ + Title: "Easy Fractions", + Prompt: prompt, + CorrectAnswer: fmt.Sprintf("%d", answer), + WorkedSolution: []string{ + fmt.Sprintf("Find one part first: %d ÷ %d = %d.", denominator*multiplier, denominator, multiplier), + fmt.Sprintf("Then take %d parts: %d × %d = %d.", numerator, multiplier, numerator, answer), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicFractions, difficulty, "single_digit", "fraction_of_amount"), + } + } + + if difficulty == sqlc.QuestionDifficultyMedium { + denominator := randomInt(rng, 3, 10) + a := randomInt(rng, 1, denominator-1) + b := randomInt(rng, 1, denominator-1) + resultN, resultD := simplifyFraction(a+b, denominator) + return GeneratedQuestion{ + Title: "Medium Fractions", + Prompt: fmt.Sprintf("Work out %d/%d + %d/%d. Give your answer in simplest form.", a, denominator, b, denominator), + CorrectAnswer: formatFraction(resultN, resultD), + WorkedSolution: []string{ + fmt.Sprintf("The denominators are the same, so add the numerators: %d + %d = %d.", a, b, a+b), + fmt.Sprintf("This gives %d/%d.", a+b, denominator), + fmt.Sprintf("Simplify to %s.", formatFraction(resultN, resultD)), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicFractions, difficulty, "addition", "simplify"), + } + } + + aN := randomInt(rng, 1, 8) + aD := randomInt(rng, 2, 9) + bN := randomInt(rng, 1, 8) + bD := randomInt(rng, 2, 9) + resultN, resultD := simplifyFraction(aN*bN, aD*bD) + return GeneratedQuestion{ + Title: "Hard Fractions", + Prompt: fmt.Sprintf("Work out %d/%d × %d/%d. Give your answer in simplest form.", aN, aD, bN, bD), + CorrectAnswer: formatFraction(resultN, resultD), + WorkedSolution: []string{ + fmt.Sprintf("Multiply the numerators: %d × %d = %d.", aN, bN, aN*bN), + fmt.Sprintf("Multiply the denominators: %d × %d = %d.", aD, bD, aD*bD), + fmt.Sprintf("This gives %d/%d, which simplifies to %s.", aN*bN, aD*bD, formatFraction(resultN, resultD)), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicFractions, difficulty, "multiplication", "simplify"), + } +} + +func generateAlgebraQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion { + if difficulty == sqlc.QuestionDifficultyEasy { + x := randomInt(rng, 1, 12) + a := randomInt(rng, 1, 12) + b := x + a + return GeneratedQuestion{ + Title: "Easy Algebra", + Prompt: fmt.Sprintf("Solve x + %d = %d.", a, b), + CorrectAnswer: fmt.Sprintf("x = %d", x), + WorkedSolution: []string{ + fmt.Sprintf("Subtract %d from both sides.", a), + fmt.Sprintf("x = %d - %d = %d.", b, a, x), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicAlgebra, difficulty, "one_step"), + } + } + + if difficulty == sqlc.QuestionDifficultyMedium { + x := randomInt(rng, 2, 12) + a := randomInt(rng, 2, 9) + b := randomInt(rng, 1, 12) + c := a*x + b + return GeneratedQuestion{ + Title: "Medium Algebra", + Prompt: fmt.Sprintf("Solve %dx + %d = %d.", a, b, c), + CorrectAnswer: fmt.Sprintf("x = %d", x), + WorkedSolution: []string{ + fmt.Sprintf("Subtract %d from both sides to get %dx = %d.", b, a, c-b), + fmt.Sprintf("Divide both sides by %d, so x = %d.", a, x), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicAlgebra, difficulty, "two_step"), + } + } + + x := randomInt(rng, -6, 12) + a := randomInt(rng, 2, 6) + b := randomInt(rng, 1, 8) + c := randomInt(rng, 2, 6) + d := a*(x+b) - c + return GeneratedQuestion{ + Title: "Hard Algebra", + Prompt: fmt.Sprintf("Solve %d(x + %d) - %d = %d.", a, b, c, d), + CorrectAnswer: fmt.Sprintf("x = %d", x), + WorkedSolution: []string{ + fmt.Sprintf("Add %d to both sides: %d(x + %d) = %d.", c, a, b, d+c), + fmt.Sprintf("Divide by %d: x + %d = %d.", a, b, x+b), + fmt.Sprintf("Subtract %d, so x = %d.", b, x), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicAlgebra, difficulty, "brackets", "multi_step"), + } +} + +func generateGeometryQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion { + if difficulty == sqlc.QuestionDifficultyEasy { + side := randomInt(rng, 2, 9) + perimeter := side * 4 + return GeneratedQuestion{ + Title: "Easy Geometry", + Prompt: fmt.Sprintf("A square has side length %d cm. What is its perimeter?", side), + CorrectAnswer: fmt.Sprintf("%d cm", perimeter), + WorkedSolution: []string{ + fmt.Sprintf("A square has 4 equal sides, so calculate 4 × %d.", side), + fmt.Sprintf("The perimeter is %d cm.", perimeter), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicGeometry, difficulty, "perimeter", "square"), + } + } + + if difficulty == sqlc.QuestionDifficultyMedium { + length := randomInt(rng, 4, 15) + width := randomInt(rng, 3, 12) + area := length * width + return GeneratedQuestion{ + Title: "Medium Geometry", + Prompt: fmt.Sprintf("A rectangle has length %d cm and width %d cm. What is its area?", length, width), + CorrectAnswer: fmt.Sprintf("%d cm²", area), + WorkedSolution: []string{ + fmt.Sprintf("Area of a rectangle = length × width."), + fmt.Sprintf("%d × %d = %d, so the area is %d cm².", length, width, area, area), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicGeometry, difficulty, "area", "rectangle"), + } + } + + a := randomInt(rng, 20, 100) + b := randomInt(rng, 20, 100) + missing := 180 - a - b + return GeneratedQuestion{ + Title: "Hard Geometry", + Prompt: fmt.Sprintf("Two angles in a triangle are %d° and %d°. Find the third angle.", a, b), + CorrectAnswer: fmt.Sprintf("%d°", missing), + WorkedSolution: []string{ + fmt.Sprintf("Angles in a triangle add to 180°."), + fmt.Sprintf("First add the known angles: %d + %d = %d.", a, b, a+b), + fmt.Sprintf("Then calculate 180 - %d = %d°.", a+b, missing), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicGeometry, difficulty, "angles", "triangle"), + } +} + +func generateDataQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion { + if difficulty == sqlc.QuestionDifficultyEasy { + values := sortedRandomValues(rng, 5, 1, 9) + median := values[len(values)/2] + return GeneratedQuestion{ + Title: "Easy Data", + Prompt: fmt.Sprintf("Find the median of these numbers: %s.", joinInts(values)), + CorrectAnswer: fmt.Sprintf("%d", median), + WorkedSolution: []string{ + fmt.Sprintf("Put the numbers in order: %s.", joinInts(values)), + fmt.Sprintf("The middle value is %d, so the median is %d.", median, median), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicData, difficulty, "median"), + } + } + + if difficulty == sqlc.QuestionDifficultyMedium { + values := sortedRandomValues(rng, 5, 2, 20) + sum := 0 + for _, value := range values { + sum += value + } + mean := sum / len(values) + adjustment := sum % len(values) + if adjustment != 0 { + values[len(values)-1] += len(values) - adjustment + sort.Ints(values) + sum = 0 + for _, value := range values { + sum += value + } + mean = sum / len(values) + } + return GeneratedQuestion{ + Title: "Medium Data", + Prompt: fmt.Sprintf("Find the mean of these numbers: %s.", joinInts(values)), + CorrectAnswer: fmt.Sprintf("%d", mean), + WorkedSolution: []string{ + fmt.Sprintf("Add the numbers: the total is %d.", sum), + fmt.Sprintf("Divide by %d: %d ÷ %d = %d.", len(values), sum, len(values), mean), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicData, difficulty, "mean"), + } + } + + values := sortedRandomValues(rng, 6, 5, 30) + modeIndex := randomInt(rng, 0, len(values)-1) + modeValue := values[modeIndex] + values = append(values, modeValue) + sort.Ints(values) + return GeneratedQuestion{ + Title: "Hard Data", + Prompt: fmt.Sprintf("Find the mode of these numbers: %s.", joinInts(values)), + CorrectAnswer: fmt.Sprintf("%d", modeValue), + WorkedSolution: []string{ + fmt.Sprintf("The mode is the value that appears most often."), + fmt.Sprintf("%d appears more than any other value, so the mode is %d.", modeValue, modeValue), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicData, difficulty, "mode"), + } +} + +func randomInt(rng *rand.Rand, min, max int) int { + if max <= min { + return min + } + return min + rng.Intn(max-min+1) +} + +func digitsToInt(digits []int) int { + value := 0 + for _, digit := range digits { + value = value*10 + digit + } + return value +} + +func placeNameFromPower(power int) string { + switch power { + case 0: + return "ones" + case 1: + return "tens" + case 2: + return "hundreds" + case 3: + return "thousands" + case 4: + return "ten-thousands" + default: + return "place" + } +} + +func gcd(a, b int) int { + for b != 0 { + a, b = b, a%b + } + if a < 0 { + return -a + } + return a +} + +func simplifyFraction(numerator, denominator int) (int, int) { + if denominator == 0 { + return numerator, denominator + } + divisor := gcd(numerator, denominator) + return numerator / divisor, denominator / divisor +} + +func formatFraction(numerator, denominator int) string { + if denominator == 1 { + return fmt.Sprintf("%d", numerator) + } + return fmt.Sprintf("%d/%d", numerator, denominator) +} + +func sortedRandomValues(rng *rand.Rand, count, min, max int) []int { + values := make([]int, count) + for i := 0; i < count; i++ { + values[i] = randomInt(rng, min, max) + } + sort.Ints(values) + return values +} + +func joinInts(values []int) string { + parts := make([]string, 0, len(values)) + for _, value := range values { + parts = append(parts, fmt.Sprintf("%d", value)) + } + return strings.Join(parts, ", ") +} diff --git a/Backend/internal/questiongen/service_test.go b/Backend/internal/questiongen/service_test.go new file mode 100644 index 0000000..a2872e5 --- /dev/null +++ b/Backend/internal/questiongen/service_test.go @@ -0,0 +1,175 @@ +package questiongen + +import ( + "reflect" + "regexp" + "strconv" + "testing" + + "boostai-backend/internal/sqlc" +) + +func TestServiceGenerateDeterministicWithSeed(t *testing.T) { + t.Parallel() + + service := NewService() + params := GenerateParams{ + Topic: sqlc.QuestionTopicFractions, + Difficulty: sqlc.QuestionDifficultyMedium, + Count: 3, + Seed: 123456, + } + + first, firstSeed, err := service.Generate(params) + if err != nil { + t.Fatalf("first generate returned error: %v", err) + } + + second, secondSeed, err := service.Generate(params) + if err != nil { + t.Fatalf("second generate returned error: %v", err) + } + + if firstSeed != params.Seed || secondSeed != params.Seed { + t.Fatalf("expected seed %d to be reused, got %d and %d", params.Seed, firstSeed, secondSeed) + } + + if !reflect.DeepEqual(first, second) { + t.Fatalf("expected deterministic output for identical seed\nfirst: %#v\nsecond: %#v", first, second) + } +} + +func TestServiceGenerateSupportsAllTopicsAndDifficulties(t *testing.T) { + t.Parallel() + + service := NewService() + topics := []sqlc.QuestionTopic{ + sqlc.QuestionTopicPlaceValue, + sqlc.QuestionTopicArithmetic, + sqlc.QuestionTopicNegativeNumbers, + sqlc.QuestionTopicBidmas, + sqlc.QuestionTopicFractions, + sqlc.QuestionTopicAlgebra, + sqlc.QuestionTopicGeometry, + sqlc.QuestionTopicData, + } + difficulties := []sqlc.QuestionDifficulty{ + sqlc.QuestionDifficultyEasy, + sqlc.QuestionDifficultyMedium, + sqlc.QuestionDifficultyHard, + } + + for _, topic := range topics { + topic := topic + for _, difficulty := range difficulties { + difficulty := difficulty + t.Run(string(topic)+"_"+string(difficulty), func(t *testing.T) { + t.Parallel() + + items, usedSeed, err := service.Generate(GenerateParams{ + Topic: topic, + Difficulty: difficulty, + Count: 2, + Seed: 99, + }) + if err != nil { + t.Fatalf("generate returned error: %v", err) + } + if usedSeed != 99 { + t.Fatalf("expected used seed 99, got %d", usedSeed) + } + if len(items) != 2 { + t.Fatalf("expected 2 generated questions, got %d", len(items)) + } + + for i, item := range items { + if item.Title == "" { + t.Fatalf("item %d: title should not be empty", i) + } + if item.Prompt == "" { + t.Fatalf("item %d: prompt should not be empty", i) + } + if item.CorrectAnswer == "" { + t.Fatalf("item %d: correct answer should not be empty", i) + } + if len(item.WorkedSolution) == 0 { + t.Fatalf("item %d: worked solution should not be empty", i) + } + assertContainsTag(t, item.Tags, string(topic)) + assertContainsTag(t, item.Tags, string(difficulty)) + assertContainsTag(t, item.Tags, "rng_generated") + } + }) + } + } +} + +func TestFractionsEasyUsesSingleDigitPromptValues(t *testing.T) { + t.Parallel() + + service := NewService() + items, _, err := service.Generate(GenerateParams{ + Topic: sqlc.QuestionTopicFractions, + Difficulty: sqlc.QuestionDifficultyEasy, + Count: 20, + Seed: 20260522, + }) + if err != nil { + t.Fatalf("generate returned error: %v", err) + } + + for i, item := range items { + values := extractIntegers(item.Prompt) + if len(values) != 3 { + t.Fatalf("item %d: expected 3 integers in prompt, got %v from %q", i, values, item.Prompt) + } + + for _, value := range values { + if value < 0 || value > 9 { + t.Fatalf("item %d: expected easy fraction prompt values to be single-digit, got %d in %q", i, value, item.Prompt) + } + } + + assertContainsTag(t, item.Tags, "single_digit") + } +} + +func TestServiceGenerateRejectsUnsupportedTopic(t *testing.T) { + t.Parallel() + + service := NewService() + _, _, err := service.Generate(GenerateParams{ + Topic: sqlc.QuestionTopic("unsupported_topic"), + Difficulty: sqlc.QuestionDifficultyEasy, + Count: 1, + Seed: 1, + }) + if err == nil { + t.Fatal("expected unsupported topic to return an error") + } +} + +func assertContainsTag(t *testing.T, tags []string, want string) { + t.Helper() + for _, tag := range tags { + if tag == want { + return + } + } + t.Fatalf("expected tags %v to contain %q", tags, want) +} + +var integerPattern = regexp.MustCompile(`-?\d+`) + +func extractIntegers(input string) []int { + matches := integerPattern.FindAllString(input, -1) + values := make([]int, 0, len(matches)) + for _, match := range matches { + value, err := strconv.Atoi(match) + if err != nil { + continue + } + values = append(values, value) + } + return values +} diff --git a/Backend/internal/router/api.go b/Backend/internal/router/api.go new file mode 100644 index 0000000..63639dd --- /dev/null +++ b/Backend/internal/router/api.go @@ -0,0 +1,16 @@ +package router + +import ( + "boostai-backend/internal/config" + "boostai-backend/internal/database" + "boostai-backend/internal/handlers/api" + authmw "boostai-backend/internal/middleware" + + "github.com/gofiber/fiber/v2" +) + +func registerAPIRoutes(app *fiber.App, cfg *config.Config, db *database.DB, authMiddleware *authmw.AuthMiddleware) { + apiHandler := api.NewHandler(db, cfg) + apiGroup := app.Group("", authMiddleware.RequireAuth()) + apiHandler.Register(apiGroup, authMiddleware) +} diff --git a/Backend/internal/router/router.go b/Backend/internal/router/router.go new file mode 100644 index 0000000..ea7eb39 --- /dev/null +++ b/Backend/internal/router/router.go @@ -0,0 +1,24 @@ +// Path: Backend/internal/router/router.go + +package router + +import ( + "boostai-backend/internal/config" + "boostai-backend/internal/database" + + "github.com/gofiber/fiber/v2" +) + +func Setup(app *fiber.App, cfg *config.Config, db *database.DB) { + authMiddleware := buildAuthMiddleware(cfg) + + registerWebRoutes(app, cfg, db, authMiddleware) + registerAPIRoutes(app, cfg, db, authMiddleware) + + app.Use(func(c *fiber.Ctx) error { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "not_found", + "message": "The requested endpoint does not exist", + }) + }) +} diff --git a/Backend/internal/router/web.go b/Backend/internal/router/web.go new file mode 100644 index 0000000..7f7b7b7 --- /dev/null +++ b/Backend/internal/router/web.go @@ -0,0 +1,32 @@ +package router + +import ( + "boostai-backend/internal/config" + "boostai-backend/internal/database" + webAuth "boostai-backend/internal/handlers/web/auth" + "boostai-backend/internal/handlers/web/health" + "boostai-backend/internal/handlers/web/root" + authmw "boostai-backend/internal/middleware" + + "github.com/gofiber/fiber/v2" +) + +func buildAuthMiddleware(cfg *config.Config) *authmw.AuthMiddleware { + return authmw.NewAuthMiddleware(cfg) +} + +func registerWebRoutes(app *fiber.App, cfg *config.Config, db *database.DB, authMiddleware *authmw.AuthMiddleware) { + rootHandler := root.NewHandler() + healthHandler := health.NewHandler(cfg.Environment, db) + authHandler := webAuth.NewHandler(cfg, db, authMiddleware) + + app.Get("/", rootHandler.Index) + app.Get("/health", healthHandler.Check) + + authGroup := app.Group("/auth") + authGroup.Post("/register", authHandler.RegisterUser) + authGroup.Post("/login", authHandler.Login) + authGroup.Get("/me", authMiddleware.RequireAuth(), authHandler.Me) + authGroup.Patch("/me", authMiddleware.RequireAuth(), authHandler.UpdateMe) + authGroup.Post("/logout", authMiddleware.RequireAuth(), authHandler.Logout) +} diff --git a/Backend/internal/sqlc/assignments.sql.go b/Backend/internal/sqlc/assignments.sql.go new file mode 100644 index 0000000..a4fcf0e --- /dev/null +++ b/Backend/internal/sqlc/assignments.sql.go @@ -0,0 +1,1069 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: assignments.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const addAssignmentStudentQuestion = `-- name: AddAssignmentStudentQuestion :one +INSERT INTO assignment_student_questions ( + assignment_id, + student_id, + question_id, + position, + source_bucket, + source_topic, + source_difficulty, + generator_seed +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8 +) +RETURNING id, assignment_id, student_id, question_id, position, source_bucket, source_topic, source_difficulty, generator_seed, created_at +` + +type AddAssignmentStudentQuestionParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + QuestionID int64 `json:"question_id"` + Position int32 `json:"position"` + SourceBucket string `json:"source_bucket"` + SourceTopic NullQuestionTopic `json:"source_topic"` + SourceDifficulty NullQuestionDifficulty `json:"source_difficulty"` + GeneratorSeed pgtype.Int8 `json:"generator_seed"` +} + +func (q *Queries) AddAssignmentStudentQuestion(ctx context.Context, arg AddAssignmentStudentQuestionParams) (AssignmentStudentQuestion, error) { + row := q.db.QueryRow(ctx, addAssignmentStudentQuestion, + arg.AssignmentID, + arg.StudentID, + arg.QuestionID, + arg.Position, + arg.SourceBucket, + arg.SourceTopic, + arg.SourceDifficulty, + arg.GeneratorSeed, + ) + var i AssignmentStudentQuestion + err := row.Scan( + &i.ID, + &i.AssignmentID, + &i.StudentID, + &i.QuestionID, + &i.Position, + &i.SourceBucket, + &i.SourceTopic, + &i.SourceDifficulty, + &i.GeneratorSeed, + &i.CreatedAt, + ) + return i, err +} + +const addQuestionToAssignment = `-- name: AddQuestionToAssignment :exec +INSERT INTO assignment_questions ( + assignment_id, + question_id, + position +) VALUES ( + $1, + $2, + $3 +) +ON CONFLICT (assignment_id, question_id) DO UPDATE +SET position = EXCLUDED.position +` + +type AddQuestionToAssignmentParams struct { + AssignmentID int64 `json:"assignment_id"` + QuestionID int64 `json:"question_id"` + Position int32 `json:"position"` +} + +func (q *Queries) AddQuestionToAssignment(ctx context.Context, arg AddQuestionToAssignmentParams) error { + _, err := q.db.Exec(ctx, addQuestionToAssignment, arg.AssignmentID, arg.QuestionID, arg.Position) + return err +} + +const assignStudentToAssignment = `-- name: AssignStudentToAssignment :exec +INSERT INTO assignment_assignees ( + assignment_id, + student_id +) VALUES ( + $1, + $2 +) +ON CONFLICT (assignment_id, student_id) DO NOTHING +` + +type AssignStudentToAssignmentParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` +} + +func (q *Queries) AssignStudentToAssignment(ctx context.Context, arg AssignStudentToAssignmentParams) error { + _, err := q.db.Exec(ctx, assignStudentToAssignment, arg.AssignmentID, arg.StudentID) + return err +} + +const closeAssignment = `-- name: CloseAssignment :one +UPDATE assignments +SET status = 'closed'::assignment_status, + updated_at = NOW() +WHERE id = $1 +RETURNING id, classroom_id, teacher_id, title, instructions, status, due_at, published_at, created_at, updated_at, pass_threshold +` + +func (q *Queries) CloseAssignment(ctx context.Context, id int64) (Assignment, error) { + row := q.db.QueryRow(ctx, closeAssignment, id) + var i Assignment + err := row.Scan( + &i.ID, + &i.ClassroomID, + &i.TeacherID, + &i.Title, + &i.Instructions, + &i.Status, + &i.DueAt, + &i.PublishedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.PassThreshold, + ) + return i, err +} + +const createAssignment = `-- name: CreateAssignment :one +INSERT INTO assignments ( + classroom_id, + teacher_id, + title, + instructions, + status, + due_at, + published_at, + pass_threshold +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8 +) +RETURNING id, classroom_id, teacher_id, title, instructions, status, due_at, published_at, created_at, updated_at, pass_threshold +` + +type CreateAssignmentParams struct { + ClassroomID int64 `json:"classroom_id"` + TeacherID int64 `json:"teacher_id"` + Title string `json:"title"` + Instructions pgtype.Text `json:"instructions"` + Status AssignmentStatus `json:"status"` + DueAt pgtype.Timestamptz `json:"due_at"` + PublishedAt pgtype.Timestamptz `json:"published_at"` + PassThreshold pgtype.Numeric `json:"pass_threshold"` +} + +func (q *Queries) CreateAssignment(ctx context.Context, arg CreateAssignmentParams) (Assignment, error) { + row := q.db.QueryRow(ctx, createAssignment, + arg.ClassroomID, + arg.TeacherID, + arg.Title, + arg.Instructions, + arg.Status, + arg.DueAt, + arg.PublishedAt, + arg.PassThreshold, + ) + var i Assignment + err := row.Scan( + &i.ID, + &i.ClassroomID, + &i.TeacherID, + &i.Title, + &i.Instructions, + &i.Status, + &i.DueAt, + &i.PublishedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.PassThreshold, + ) + return i, err +} + +const deleteAssignmentAssignee = `-- name: DeleteAssignmentAssignee :exec +DELETE FROM assignment_assignees +WHERE assignment_id = $1 + AND student_id = $2 +` + +type DeleteAssignmentAssigneeParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` +} + +func (q *Queries) DeleteAssignmentAssignee(ctx context.Context, arg DeleteAssignmentAssigneeParams) error { + _, err := q.db.Exec(ctx, deleteAssignmentAssignee, arg.AssignmentID, arg.StudentID) + return err +} + +const deleteAssignmentStudentQuestions = `-- name: DeleteAssignmentStudentQuestions :exec +DELETE FROM assignment_student_questions +WHERE assignment_id = $1 + AND student_id = $2 +` + +type DeleteAssignmentStudentQuestionsParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` +} + +func (q *Queries) DeleteAssignmentStudentQuestions(ctx context.Context, arg DeleteAssignmentStudentQuestionsParams) error { + _, err := q.db.Exec(ctx, deleteAssignmentStudentQuestions, arg.AssignmentID, arg.StudentID) + return err +} + +const getAssignmentAssignee = `-- name: GetAssignmentAssignee :one +SELECT assignment_id, student_id, assigned_at, ai_feedback, teacher_feedback, overall_score, pass_threshold, pass_status, pass_status_override, next_step_outcome, redo_plan, redo_plan_generated_at +FROM assignment_assignees +WHERE assignment_id = $1 + AND student_id = $2 +` + +type GetAssignmentAssigneeParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` +} + +func (q *Queries) GetAssignmentAssignee(ctx context.Context, arg GetAssignmentAssigneeParams) (AssignmentAssignee, error) { + row := q.db.QueryRow(ctx, getAssignmentAssignee, arg.AssignmentID, arg.StudentID) + var i AssignmentAssignee + err := row.Scan( + &i.AssignmentID, + &i.StudentID, + &i.AssignedAt, + &i.AiFeedback, + &i.TeacherFeedback, + &i.OverallScore, + &i.PassThreshold, + &i.PassStatus, + &i.PassStatusOverride, + &i.NextStepOutcome, + &i.RedoPlan, + &i.RedoPlanGeneratedAt, + ) + return i, err +} + +const getAssignmentByID = `-- name: GetAssignmentByID :one +SELECT id, classroom_id, teacher_id, title, instructions, status, due_at, published_at, created_at, updated_at, pass_threshold +FROM assignments +WHERE id = $1 +` + +func (q *Queries) GetAssignmentByID(ctx context.Context, id int64) (Assignment, error) { + row := q.db.QueryRow(ctx, getAssignmentByID, id) + var i Assignment + err := row.Scan( + &i.ID, + &i.ClassroomID, + &i.TeacherID, + &i.Title, + &i.Instructions, + &i.Status, + &i.DueAt, + &i.PublishedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.PassThreshold, + ) + return i, err +} + +const getAssignmentRedoPlan = `-- name: GetAssignmentRedoPlan :one +SELECT + assignment_id, + student_id, + redo_plan, + redo_plan_generated_at +FROM assignment_assignees +WHERE assignment_id = $1 + AND student_id = $2 +` + +type GetAssignmentRedoPlanParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` +} + +type GetAssignmentRedoPlanRow struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + RedoPlan pgtype.Text `json:"redo_plan"` + RedoPlanGeneratedAt pgtype.Timestamptz `json:"redo_plan_generated_at"` +} + +func (q *Queries) GetAssignmentRedoPlan(ctx context.Context, arg GetAssignmentRedoPlanParams) (GetAssignmentRedoPlanRow, error) { + row := q.db.QueryRow(ctx, getAssignmentRedoPlan, arg.AssignmentID, arg.StudentID) + var i GetAssignmentRedoPlanRow + err := row.Scan( + &i.AssignmentID, + &i.StudentID, + &i.RedoPlan, + &i.RedoPlanGeneratedAt, + ) + return i, err +} + +const getAssignmentReviewSummary = `-- name: GetAssignmentReviewSummary :one +WITH student_question_set AS ( + SELECT asq.student_id, asq.question_id, asq.position + FROM assignment_student_questions asq + WHERE asq.assignment_id = $1 +), +students_with_personalized AS ( + SELECT DISTINCT student_id + FROM student_question_set +), +selected_questions AS ( + SELECT student_id, question_id, position + FROM student_question_set + UNION ALL + SELECT aa.student_id, aq.question_id, aq.position + FROM assignment_assignees aa + JOIN assignment_questions aq ON aq.assignment_id = aa.assignment_id + WHERE aa.assignment_id = $1 + AND NOT EXISTS ( + SELECT 1 + FROM students_with_personalized swp + WHERE swp.student_id = aa.student_id + ) +), +student_states AS ( + SELECT + aa.student_id, + COUNT(sq.question_id)::BIGINT AS total_questions, + CASE + WHEN COUNT(sa.id) = 0 THEN 'not_started'::answer_status + WHEN COUNT(sq.question_id) > 0 + AND COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') = COUNT(sq.question_id) + THEN 'reviewed'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'submitted') > 0 THEN 'submitted'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'in_progress') > 0 THEN 'in_progress'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') > 0 THEN 'in_progress'::answer_status + ELSE 'not_started'::answer_status + END AS review_status + FROM assignment_assignees aa + LEFT JOIN selected_questions sq + ON sq.student_id = aa.student_id + LEFT JOIN student_answers sa + ON sa.assignment_id = aa.assignment_id + AND sa.question_id = sq.question_id + AND sa.student_id = aa.student_id + WHERE aa.assignment_id = $1 + GROUP BY aa.student_id +) +SELECT + $1::BIGINT AS assignment_id, + COALESCE(MAX(student_states.total_questions), 0)::BIGINT AS total_questions, + COUNT(*)::BIGINT AS total_assigned, + COUNT(*) FILTER (WHERE review_status = 'not_started')::BIGINT AS not_started, + COUNT(*) FILTER (WHERE review_status = 'in_progress')::BIGINT AS in_progress, + COUNT(*) FILTER (WHERE review_status = 'submitted')::BIGINT AS submitted, + COUNT(*) FILTER (WHERE review_status = 'reviewed')::BIGINT AS reviewed +FROM student_states +` + +type GetAssignmentReviewSummaryRow struct { + AssignmentID int64 `json:"assignment_id"` + TotalQuestions int64 `json:"total_questions"` + TotalAssigned int64 `json:"total_assigned"` + NotStarted int64 `json:"not_started"` + InProgress int64 `json:"in_progress"` + Submitted int64 `json:"submitted"` + Reviewed int64 `json:"reviewed"` +} + +func (q *Queries) GetAssignmentReviewSummary(ctx context.Context, dollar_1 int64) (GetAssignmentReviewSummaryRow, error) { + row := q.db.QueryRow(ctx, getAssignmentReviewSummary, dollar_1) + var i GetAssignmentReviewSummaryRow + err := row.Scan( + &i.AssignmentID, + &i.TotalQuestions, + &i.TotalAssigned, + &i.NotStarted, + &i.InProgress, + &i.Submitted, + &i.Reviewed, + ) + return i, err +} + +const listAssignmentReviewQueue = `-- name: ListAssignmentReviewQueue :many +WITH student_question_set AS ( + SELECT asq.student_id, asq.question_id, asq.position + FROM assignment_student_questions asq + WHERE asq.assignment_id = $1 +), +students_with_personalized AS ( + SELECT DISTINCT student_id + FROM student_question_set +), +selected_questions AS ( + SELECT student_id, question_id, position + FROM student_question_set + UNION ALL + SELECT aa.student_id, aq.question_id, aq.position + FROM assignment_assignees aa + JOIN assignment_questions aq ON aq.assignment_id = aa.assignment_id + WHERE aa.assignment_id = $1 + AND NOT EXISTS ( + SELECT 1 + FROM students_with_personalized swp + WHERE swp.student_id = aa.student_id + ) +), +student_states AS ( + SELECT + aa.assignment_id, + aa.student_id, + aa.next_step_outcome, + u.full_name AS student_name, + u.email AS student_email, + COUNT(sq.question_id)::BIGINT AS total_questions, + COUNT(sa.id)::BIGINT AS answered_questions, + COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed')::BIGINT AS reviewed_questions, + COUNT(sa.id) FILTER (WHERE sa.status = 'submitted')::BIGINT AS submitted_questions, + COUNT(sa.id) FILTER (WHERE sa.status = 'in_progress')::BIGINT AS in_progress_questions, + MAX(sa.submitted_at)::timestamptz AS latest_submitted_at, + MAX(sa.reviewed_at)::timestamptz AS latest_reviewed_at, + CASE + WHEN COUNT(sa.id) = 0 THEN 'not_started'::answer_status + WHEN COUNT(sq.question_id) > 0 + AND COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') = COUNT(sq.question_id) + THEN 'reviewed'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'submitted') > 0 THEN 'submitted'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'in_progress') > 0 THEN 'in_progress'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') > 0 THEN 'in_progress'::answer_status + ELSE 'not_started'::answer_status + END AS review_status + FROM assignment_assignees aa + JOIN users u ON u.id = aa.student_id + LEFT JOIN selected_questions sq + ON sq.student_id = aa.student_id + LEFT JOIN student_answers sa + ON sa.assignment_id = aa.assignment_id + AND sa.question_id = sq.question_id + AND sa.student_id = aa.student_id + WHERE aa.assignment_id = $1 + GROUP BY aa.assignment_id, aa.student_id, aa.next_step_outcome, u.full_name, u.email +) +SELECT + student_states.assignment_id, + student_states.student_id, + student_states.next_step_outcome, + student_states.student_name, + student_states.student_email, + student_states.total_questions, + student_states.answered_questions, + student_states.reviewed_questions, + student_states.submitted_questions, + student_states.in_progress_questions, + student_states.review_status, + student_states.latest_submitted_at, + student_states.latest_reviewed_at +FROM student_states +WHERE ($2::text = '' OR review_status::text = $2::text) +ORDER BY student_states.student_name ASC, student_states.student_id ASC +` + +type ListAssignmentReviewQueueParams struct { + AssignmentID int64 `json:"assignment_id"` + Column2 string `json:"column_2"` +} + +type ListAssignmentReviewQueueRow struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + NextStepOutcome NullAssignmentNextStepOutcome `json:"next_step_outcome"` + StudentName string `json:"student_name"` + StudentEmail string `json:"student_email"` + TotalQuestions int64 `json:"total_questions"` + AnsweredQuestions int64 `json:"answered_questions"` + ReviewedQuestions int64 `json:"reviewed_questions"` + SubmittedQuestions int64 `json:"submitted_questions"` + InProgressQuestions int64 `json:"in_progress_questions"` + ReviewStatus AnswerStatus `json:"review_status"` + LatestSubmittedAt pgtype.Timestamptz `json:"latest_submitted_at"` + LatestReviewedAt pgtype.Timestamptz `json:"latest_reviewed_at"` +} + +func (q *Queries) ListAssignmentReviewQueue(ctx context.Context, arg ListAssignmentReviewQueueParams) ([]ListAssignmentReviewQueueRow, error) { + rows, err := q.db.Query(ctx, listAssignmentReviewQueue, arg.AssignmentID, arg.Column2) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListAssignmentReviewQueueRow{} + for rows.Next() { + var i ListAssignmentReviewQueueRow + if err := rows.Scan( + &i.AssignmentID, + &i.StudentID, + &i.NextStepOutcome, + &i.StudentName, + &i.StudentEmail, + &i.TotalQuestions, + &i.AnsweredQuestions, + &i.ReviewedQuestions, + &i.SubmittedQuestions, + &i.InProgressQuestions, + &i.ReviewStatus, + &i.LatestSubmittedAt, + &i.LatestReviewedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAssignmentStudentQuestions = `-- name: ListAssignmentStudentQuestions :many +SELECT id, assignment_id, student_id, question_id, position, source_bucket, source_topic, source_difficulty, generator_seed, created_at +FROM assignment_student_questions +WHERE assignment_id = $1 + AND student_id = $2 +ORDER BY position ASC, id ASC +` + +type ListAssignmentStudentQuestionsParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` +} + +func (q *Queries) ListAssignmentStudentQuestions(ctx context.Context, arg ListAssignmentStudentQuestionsParams) ([]AssignmentStudentQuestion, error) { + rows, err := q.db.Query(ctx, listAssignmentStudentQuestions, arg.AssignmentID, arg.StudentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []AssignmentStudentQuestion{} + for rows.Next() { + var i AssignmentStudentQuestion + if err := rows.Scan( + &i.ID, + &i.AssignmentID, + &i.StudentID, + &i.QuestionID, + &i.Position, + &i.SourceBucket, + &i.SourceTopic, + &i.SourceDifficulty, + &i.GeneratorSeed, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAssignmentsByTeacher = `-- name: ListAssignmentsByTeacher :many +SELECT id, classroom_id, teacher_id, title, instructions, status, due_at, published_at, created_at, updated_at, pass_threshold +FROM assignments +WHERE teacher_id = $1 +ORDER BY created_at DESC +` + +func (q *Queries) ListAssignmentsByTeacher(ctx context.Context, teacherID int64) ([]Assignment, error) { + rows, err := q.db.Query(ctx, listAssignmentsByTeacher, teacherID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Assignment{} + for rows.Next() { + var i Assignment + if err := rows.Scan( + &i.ID, + &i.ClassroomID, + &i.TeacherID, + &i.Title, + &i.Instructions, + &i.Status, + &i.DueAt, + &i.PublishedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.PassThreshold, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAssignmentsForStudent = `-- name: ListAssignmentsForStudent :many +SELECT a.id, a.classroom_id, a.teacher_id, a.title, a.instructions, a.status, a.due_at, a.published_at, a.created_at, a.updated_at, a.pass_threshold +FROM assignment_assignees aa +JOIN assignments a ON a.id = aa.assignment_id +WHERE aa.student_id = $1 +ORDER BY a.created_at DESC +` + +func (q *Queries) ListAssignmentsForStudent(ctx context.Context, studentID int64) ([]Assignment, error) { + rows, err := q.db.Query(ctx, listAssignmentsForStudent, studentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Assignment{} + for rows.Next() { + var i Assignment + if err := rows.Scan( + &i.ID, + &i.ClassroomID, + &i.TeacherID, + &i.Title, + &i.Instructions, + &i.Status, + &i.DueAt, + &i.PublishedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.PassThreshold, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listGeneratedQuestionsForAssignmentStudent = `-- name: ListGeneratedQuestionsForAssignmentStudent :many +SELECT + asq.id, + asq.assignment_id, + asq.student_id, + asq.question_id, + asq.position, + asq.source_bucket, + asq.source_topic, + asq.source_difficulty, + asq.generator_seed, + asq.created_at, + q.author_teacher_id, + q.title, + q.prompt, + q.subject, + q.source, + q.status, + q.created_at AS question_created_at, + q.updated_at AS question_updated_at, + q.correct_answer, + q.topic, + q.difficulty +FROM assignment_student_questions asq +JOIN questions q ON q.id = asq.question_id +WHERE asq.assignment_id = $1 + AND asq.student_id = $2 +ORDER BY asq.position ASC, asq.id ASC +` + +type ListGeneratedQuestionsForAssignmentStudentParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` +} + +type ListGeneratedQuestionsForAssignmentStudentRow struct { + ID int64 `json:"id"` + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + QuestionID int64 `json:"question_id"` + Position int32 `json:"position"` + SourceBucket string `json:"source_bucket"` + SourceTopic NullQuestionTopic `json:"source_topic"` + SourceDifficulty NullQuestionDifficulty `json:"source_difficulty"` + GeneratorSeed pgtype.Int8 `json:"generator_seed"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + AuthorTeacherID int64 `json:"author_teacher_id"` + Title string `json:"title"` + Prompt string `json:"prompt"` + Subject pgtype.Text `json:"subject"` + Source pgtype.Text `json:"source"` + Status QuestionStatus `json:"status"` + QuestionCreatedAt pgtype.Timestamptz `json:"question_created_at"` + QuestionUpdatedAt pgtype.Timestamptz `json:"question_updated_at"` + CorrectAnswer pgtype.Text `json:"correct_answer"` + Topic NullQuestionTopic `json:"topic"` + Difficulty NullQuestionDifficulty `json:"difficulty"` +} + +func (q *Queries) ListGeneratedQuestionsForAssignmentStudent(ctx context.Context, arg ListGeneratedQuestionsForAssignmentStudentParams) ([]ListGeneratedQuestionsForAssignmentStudentRow, error) { + rows, err := q.db.Query(ctx, listGeneratedQuestionsForAssignmentStudent, arg.AssignmentID, arg.StudentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListGeneratedQuestionsForAssignmentStudentRow{} + for rows.Next() { + var i ListGeneratedQuestionsForAssignmentStudentRow + if err := rows.Scan( + &i.ID, + &i.AssignmentID, + &i.StudentID, + &i.QuestionID, + &i.Position, + &i.SourceBucket, + &i.SourceTopic, + &i.SourceDifficulty, + &i.GeneratorSeed, + &i.CreatedAt, + &i.AuthorTeacherID, + &i.Title, + &i.Prompt, + &i.Subject, + &i.Source, + &i.Status, + &i.QuestionCreatedAt, + &i.QuestionUpdatedAt, + &i.CorrectAnswer, + &i.Topic, + &i.Difficulty, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listQuestionsForAssignment = `-- name: ListQuestionsForAssignment :many +SELECT + aq.assignment_id, + aq.question_id, + aq.position, + q.author_teacher_id, + q.title, + q.prompt, + q.subject, + q.source, + q.status, + q.created_at, + q.updated_at +FROM assignment_questions aq +JOIN questions q ON q.id = aq.question_id +WHERE aq.assignment_id = $1 +ORDER BY aq.position ASC, aq.question_id ASC +` + +type ListQuestionsForAssignmentRow struct { + AssignmentID int64 `json:"assignment_id"` + QuestionID int64 `json:"question_id"` + Position int32 `json:"position"` + AuthorTeacherID int64 `json:"author_teacher_id"` + Title string `json:"title"` + Prompt string `json:"prompt"` + Subject pgtype.Text `json:"subject"` + Source pgtype.Text `json:"source"` + Status QuestionStatus `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) ListQuestionsForAssignment(ctx context.Context, assignmentID int64) ([]ListQuestionsForAssignmentRow, error) { + rows, err := q.db.Query(ctx, listQuestionsForAssignment, assignmentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListQuestionsForAssignmentRow{} + for rows.Next() { + var i ListQuestionsForAssignmentRow + if err := rows.Scan( + &i.AssignmentID, + &i.QuestionID, + &i.Position, + &i.AuthorTeacherID, + &i.Title, + &i.Prompt, + &i.Subject, + &i.Source, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateAssignmentAIReview = `-- name: UpdateAssignmentAIReview :one +UPDATE assignment_assignees +SET ai_feedback = $3, + next_step_outcome = NULLIF($4::text, '')::assignment_next_step_outcome +WHERE assignment_id = $1 + AND student_id = $2 +RETURNING assignment_id, student_id, assigned_at, ai_feedback, teacher_feedback, overall_score, pass_threshold, pass_status, pass_status_override, next_step_outcome, redo_plan, redo_plan_generated_at +` + +type UpdateAssignmentAIReviewParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + AiFeedback pgtype.Text `json:"ai_feedback"` + Column4 string `json:"column_4"` +} + +func (q *Queries) UpdateAssignmentAIReview(ctx context.Context, arg UpdateAssignmentAIReviewParams) (AssignmentAssignee, error) { + row := q.db.QueryRow(ctx, updateAssignmentAIReview, + arg.AssignmentID, + arg.StudentID, + arg.AiFeedback, + arg.Column4, + ) + var i AssignmentAssignee + err := row.Scan( + &i.AssignmentID, + &i.StudentID, + &i.AssignedAt, + &i.AiFeedback, + &i.TeacherFeedback, + &i.OverallScore, + &i.PassThreshold, + &i.PassStatus, + &i.PassStatusOverride, + &i.NextStepOutcome, + &i.RedoPlan, + &i.RedoPlanGeneratedAt, + ) + return i, err +} + +const updateAssignmentDraft = `-- name: UpdateAssignmentDraft :one +UPDATE assignments +SET classroom_id = $2, + title = $3, + instructions = $4, + due_at = $5, + pass_threshold = $6, + updated_at = NOW() +WHERE id = $1 +RETURNING id, classroom_id, teacher_id, title, instructions, status, due_at, published_at, created_at, updated_at, pass_threshold +` + +type UpdateAssignmentDraftParams struct { + ID int64 `json:"id"` + ClassroomID int64 `json:"classroom_id"` + Title string `json:"title"` + Instructions pgtype.Text `json:"instructions"` + DueAt pgtype.Timestamptz `json:"due_at"` + PassThreshold pgtype.Numeric `json:"pass_threshold"` +} + +func (q *Queries) UpdateAssignmentDraft(ctx context.Context, arg UpdateAssignmentDraftParams) (Assignment, error) { + row := q.db.QueryRow(ctx, updateAssignmentDraft, + arg.ID, + arg.ClassroomID, + arg.Title, + arg.Instructions, + arg.DueAt, + arg.PassThreshold, + ) + var i Assignment + err := row.Scan( + &i.ID, + &i.ClassroomID, + &i.TeacherID, + &i.Title, + &i.Instructions, + &i.Status, + &i.DueAt, + &i.PublishedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.PassThreshold, + ) + return i, err +} + +const updateAssignmentRedoPlan = `-- name: UpdateAssignmentRedoPlan :one +UPDATE assignment_assignees +SET redo_plan = NULLIF($3::text, ''), + redo_plan_generated_at = CASE + WHEN NULLIF($3::text, '') IS NULL THEN NULL + ELSE NOW() + END +WHERE assignment_id = $1 + AND student_id = $2 +RETURNING assignment_id, student_id, assigned_at, ai_feedback, teacher_feedback, overall_score, pass_threshold, pass_status, pass_status_override, next_step_outcome, redo_plan, redo_plan_generated_at +` + +type UpdateAssignmentRedoPlanParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + Column3 string `json:"column_3"` +} + +func (q *Queries) UpdateAssignmentRedoPlan(ctx context.Context, arg UpdateAssignmentRedoPlanParams) (AssignmentAssignee, error) { + row := q.db.QueryRow(ctx, updateAssignmentRedoPlan, arg.AssignmentID, arg.StudentID, arg.Column3) + var i AssignmentAssignee + err := row.Scan( + &i.AssignmentID, + &i.StudentID, + &i.AssignedAt, + &i.AiFeedback, + &i.TeacherFeedback, + &i.OverallScore, + &i.PassThreshold, + &i.PassStatus, + &i.PassStatusOverride, + &i.NextStepOutcome, + &i.RedoPlan, + &i.RedoPlanGeneratedAt, + ) + return i, err +} + +const updateAssignmentTeacherFeedback = `-- name: UpdateAssignmentTeacherFeedback :one +WITH student_question_set AS ( + SELECT asq.assignment_id, asq.question_id, asq.position + FROM assignment_student_questions asq + WHERE asq.assignment_id = $1 + AND asq.student_id = $2 +), selected_questions AS ( + SELECT assignment_id, question_id, position + FROM student_question_set + UNION ALL + SELECT aq.assignment_id, aq.question_id, aq.position + FROM assignment_questions aq + WHERE aq.assignment_id = $1 + AND NOT EXISTS (SELECT 1 FROM student_question_set) +), score_summary AS ( + SELECT CASE + WHEN COUNT(sa.id) = 0 THEN NULL + ELSE ROUND((AVG( + CASE + WHEN sa.is_correct IS NULL THEN COALESCE(sa.review_understanding_score, 0)::NUMERIC + ELSE ( + ((CASE WHEN sa.is_correct THEN 1 ELSE 0 END)::NUMERIC) + COALESCE(sa.review_understanding_score, 0)::NUMERIC + ) / 2 + END + ) * 10)::NUMERIC, 2) + END AS overall_score + FROM selected_questions aq + LEFT JOIN student_answers sa + ON sa.assignment_id = aq.assignment_id + AND sa.question_id = aq.question_id + AND sa.student_id = $2 + WHERE aq.assignment_id = $1 +), updated AS ( + UPDATE assignment_assignees aa + SET teacher_feedback = $3, + pass_status_override = NULLIF($4::text, '')::assignment_pass_status, + next_step_outcome = NULLIF($5::text, '')::assignment_next_step_outcome, + overall_score = (SELECT overall_score FROM score_summary), + pass_status = COALESCE( + NULLIF($4::text, '')::assignment_pass_status, + CASE + WHEN (SELECT overall_score FROM score_summary) IS NULL THEN 'pending'::assignment_pass_status + WHEN (SELECT overall_score FROM score_summary) >= a.pass_threshold THEN 'pass'::assignment_pass_status + ELSE 'no_pass'::assignment_pass_status + END + ) + FROM assignments a + WHERE aa.assignment_id = $1 + AND aa.student_id = $2 + AND a.id = aa.assignment_id + RETURNING aa.assignment_id, aa.student_id, aa.assigned_at, aa.ai_feedback, aa.teacher_feedback, aa.overall_score, aa.pass_threshold, aa.pass_status, aa.pass_status_override, aa.next_step_outcome, aa.redo_plan, aa.redo_plan_generated_at +) +SELECT assignment_id, student_id, assigned_at, ai_feedback, teacher_feedback, overall_score, pass_threshold, pass_status, pass_status_override, next_step_outcome, redo_plan, redo_plan_generated_at +FROM updated +` + +type UpdateAssignmentTeacherFeedbackParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + TeacherFeedback pgtype.Text `json:"teacher_feedback"` + Column4 string `json:"column_4"` + Column5 string `json:"column_5"` +} + +type UpdateAssignmentTeacherFeedbackRow struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + AssignedAt pgtype.Timestamptz `json:"assigned_at"` + AiFeedback pgtype.Text `json:"ai_feedback"` + TeacherFeedback pgtype.Text `json:"teacher_feedback"` + OverallScore pgtype.Numeric `json:"overall_score"` + PassThreshold pgtype.Numeric `json:"pass_threshold"` + PassStatus AssignmentPassStatus `json:"pass_status"` + PassStatusOverride NullAssignmentPassStatus `json:"pass_status_override"` + NextStepOutcome NullAssignmentNextStepOutcome `json:"next_step_outcome"` + RedoPlan pgtype.Text `json:"redo_plan"` + RedoPlanGeneratedAt pgtype.Timestamptz `json:"redo_plan_generated_at"` +} + +func (q *Queries) UpdateAssignmentTeacherFeedback(ctx context.Context, arg UpdateAssignmentTeacherFeedbackParams) (UpdateAssignmentTeacherFeedbackRow, error) { + row := q.db.QueryRow(ctx, updateAssignmentTeacherFeedback, + arg.AssignmentID, + arg.StudentID, + arg.TeacherFeedback, + arg.Column4, + arg.Column5, + ) + var i UpdateAssignmentTeacherFeedbackRow + err := row.Scan( + &i.AssignmentID, + &i.StudentID, + &i.AssignedAt, + &i.AiFeedback, + &i.TeacherFeedback, + &i.OverallScore, + &i.PassThreshold, + &i.PassStatus, + &i.PassStatusOverride, + &i.NextStepOutcome, + &i.RedoPlan, + &i.RedoPlanGeneratedAt, + ) + return i, err +} diff --git a/Backend/internal/sqlc/classrooms.sql.go b/Backend/internal/sqlc/classrooms.sql.go new file mode 100644 index 0000000..e071aae --- /dev/null +++ b/Backend/internal/sqlc/classrooms.sql.go @@ -0,0 +1,147 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: classrooms.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const addStudentToClassroom = `-- name: AddStudentToClassroom :exec +INSERT INTO classroom_students ( + classroom_id, + student_id +) VALUES ( + $1, + $2 +) +ON CONFLICT (classroom_id, student_id) DO NOTHING +` + +type AddStudentToClassroomParams struct { + ClassroomID int64 `json:"classroom_id"` + StudentID int64 `json:"student_id"` +} + +func (q *Queries) AddStudentToClassroom(ctx context.Context, arg AddStudentToClassroomParams) error { + _, err := q.db.Exec(ctx, addStudentToClassroom, arg.ClassroomID, arg.StudentID) + return err +} + +const createClassroom = `-- name: CreateClassroom :one +INSERT INTO classrooms ( + teacher_id, + name, + code, + description +) VALUES ( + $1, + $2, + $3, + $4 +) +RETURNING id, teacher_id, name, code, description, created_at, updated_at +` + +type CreateClassroomParams struct { + TeacherID int64 `json:"teacher_id"` + Name string `json:"name"` + Code pgtype.Text `json:"code"` + Description pgtype.Text `json:"description"` +} + +func (q *Queries) CreateClassroom(ctx context.Context, arg CreateClassroomParams) (Classroom, error) { + row := q.db.QueryRow(ctx, createClassroom, + arg.TeacherID, + arg.Name, + arg.Code, + arg.Description, + ) + var i Classroom + err := row.Scan( + &i.ID, + &i.TeacherID, + &i.Name, + &i.Code, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listClassroomsByTeacher = `-- name: ListClassroomsByTeacher :many +SELECT id, teacher_id, name, code, description, created_at, updated_at +FROM classrooms +WHERE teacher_id = $1 +ORDER BY created_at DESC +` + +func (q *Queries) ListClassroomsByTeacher(ctx context.Context, teacherID int64) ([]Classroom, error) { + rows, err := q.db.Query(ctx, listClassroomsByTeacher, teacherID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Classroom{} + for rows.Next() { + var i Classroom + if err := rows.Scan( + &i.ID, + &i.TeacherID, + &i.Name, + &i.Code, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listStudentsForClassroom = `-- name: ListStudentsForClassroom :many +SELECT u.id, u.email, u.password_hash, u.role, u.full_name, u.is_active, u.created_at, u.updated_at +FROM classroom_students cs +JOIN users u ON u.id = cs.student_id +WHERE cs.classroom_id = $1 +ORDER BY u.full_name ASC +` + +func (q *Queries) ListStudentsForClassroom(ctx context.Context, classroomID int64) ([]User, error) { + rows, err := q.db.Query(ctx, listStudentsForClassroom, classroomID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []User{} + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Role, + &i.FullName, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/Backend/internal/sqlc/db.go b/Backend/internal/sqlc/db.go new file mode 100644 index 0000000..7a56507 --- /dev/null +++ b/Backend/internal/sqlc/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/Backend/internal/sqlc/messages.sql.go b/Backend/internal/sqlc/messages.sql.go new file mode 100644 index 0000000..00fee10 --- /dev/null +++ b/Backend/internal/sqlc/messages.sql.go @@ -0,0 +1,742 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: messages.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const addMessageThreadParticipant = `-- name: AddMessageThreadParticipant :exec +INSERT INTO message_thread_participants ( + thread_id, + user_id, + last_read_at +) VALUES ( + $1, + $2, + $3 +) +ON CONFLICT (thread_id, user_id) DO NOTHING +` + +type AddMessageThreadParticipantParams struct { + ThreadID int64 `json:"thread_id"` + UserID int64 `json:"user_id"` + LastReadAt pgtype.Timestamptz `json:"last_read_at"` +} + +func (q *Queries) AddMessageThreadParticipant(ctx context.Context, arg AddMessageThreadParticipantParams) error { + _, err := q.db.Exec(ctx, addMessageThreadParticipant, arg.ThreadID, arg.UserID, arg.LastReadAt) + return err +} + +const createMessageThread = `-- name: CreateMessageThread :one +INSERT INTO message_threads ( + created_by_user_id, + subject +) VALUES ( + $1, + $2 +) +RETURNING id, created_by_user_id, subject, created_at, updated_at +` + +type CreateMessageThreadParams struct { + CreatedByUserID int64 `json:"created_by_user_id"` + Subject string `json:"subject"` +} + +func (q *Queries) CreateMessageThread(ctx context.Context, arg CreateMessageThreadParams) (MessageThread, error) { + row := q.db.QueryRow(ctx, createMessageThread, arg.CreatedByUserID, arg.Subject) + var i MessageThread + err := row.Scan( + &i.ID, + &i.CreatedByUserID, + &i.Subject, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createThreadMessage = `-- name: CreateThreadMessage :one +INSERT INTO messages ( + thread_id, + sender_user_id, + body +) VALUES ( + $1, + $2, + $3 +) +RETURNING id, thread_id, sender_user_id, body, created_at, updated_at +` + +type CreateThreadMessageParams struct { + ThreadID int64 `json:"thread_id"` + SenderUserID int64 `json:"sender_user_id"` + Body string `json:"body"` +} + +func (q *Queries) CreateThreadMessage(ctx context.Context, arg CreateThreadMessageParams) (Message, error) { + row := q.db.QueryRow(ctx, createThreadMessage, arg.ThreadID, arg.SenderUserID, arg.Body) + var i Message + err := row.Scan( + &i.ID, + &i.ThreadID, + &i.SenderUserID, + &i.Body, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteMessageThread = `-- name: DeleteMessageThread :one +DELETE FROM message_threads +WHERE id = $1 +RETURNING id, created_by_user_id, subject, created_at, updated_at +` + +func (q *Queries) DeleteMessageThread(ctx context.Context, threadID int64) (MessageThread, error) { + row := q.db.QueryRow(ctx, deleteMessageThread, threadID) + var i MessageThread + err := row.Scan( + &i.ID, + &i.CreatedByUserID, + &i.Subject, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteThreadMessage = `-- name: DeleteThreadMessage :one +DELETE FROM messages +WHERE id = $1 + AND thread_id = $2 + AND sender_user_id = $3 +RETURNING id, thread_id, sender_user_id, body, created_at, updated_at +` + +type DeleteThreadMessageParams struct { + MessageID int64 `json:"message_id"` + ThreadID int64 `json:"thread_id"` + UserID int64 `json:"user_id"` +} + +func (q *Queries) DeleteThreadMessage(ctx context.Context, arg DeleteThreadMessageParams) (Message, error) { + row := q.db.QueryRow(ctx, deleteThreadMessage, arg.MessageID, arg.ThreadID, arg.UserID) + var i Message + err := row.Scan( + &i.ID, + &i.ThreadID, + &i.SenderUserID, + &i.Body, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getMessageRecipientByIDForUser = `-- name: GetMessageRecipientByIDForUser :one +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + p.preferred_name, + p.profile_icon_url, + p.headline +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.id = $2 + AND u.id <> $1 + AND u.is_active = TRUE + AND ( + EXISTS ( + SELECT 1 + FROM classrooms c + JOIN classroom_students cs ON cs.classroom_id = c.id + WHERE c.teacher_id = u.id + AND cs.student_id = $1 + ) + OR EXISTS ( + SELECT 1 + FROM classrooms c + JOIN classroom_students cs ON cs.classroom_id = c.id + WHERE c.teacher_id = $1 + AND cs.student_id = u.id + ) + ) +LIMIT 1 +` + +type GetMessageRecipientByIDForUserParams struct { + ID int64 `json:"id"` + ID_2 int64 `json:"id_2"` +} + +type GetMessageRecipientByIDForUserRow struct { + UserID int64 `json:"user_id"` + UserEmail string `json:"user_email"` + UserRole UserRole `json:"user_role"` + UserFullName string `json:"user_full_name"` + PreferredName pgtype.Text `json:"preferred_name"` + ProfileIconUrl pgtype.Text `json:"profile_icon_url"` + Headline pgtype.Text `json:"headline"` +} + +func (q *Queries) GetMessageRecipientByIDForUser(ctx context.Context, arg GetMessageRecipientByIDForUserParams) (GetMessageRecipientByIDForUserRow, error) { + row := q.db.QueryRow(ctx, getMessageRecipientByIDForUser, arg.ID, arg.ID_2) + var i GetMessageRecipientByIDForUserRow + err := row.Scan( + &i.UserID, + &i.UserEmail, + &i.UserRole, + &i.UserFullName, + &i.PreferredName, + &i.ProfileIconUrl, + &i.Headline, + ) + return i, err +} + +const getMessageThreadForUser = `-- name: GetMessageThreadForUser :one +SELECT + t.id, + t.subject, + t.created_by_user_id, + t.created_at, + t.updated_at, + participant.last_read_at, + COALESCE(( + SELECT COUNT(*)::bigint + FROM messages unread + WHERE unread.thread_id = t.id + AND unread.sender_user_id <> $2 + AND (participant.last_read_at IS NULL OR unread.created_at > participant.last_read_at) + ), 0)::bigint AS unread_count +FROM message_threads t +JOIN message_thread_participants participant ON participant.thread_id = t.id +WHERE t.id = $1 + AND participant.user_id = $2 + AND participant.archived_at IS NULL +` + +type GetMessageThreadForUserParams struct { + ID int64 `json:"id"` + SenderUserID int64 `json:"sender_user_id"` +} + +type GetMessageThreadForUserRow struct { + ID int64 `json:"id"` + Subject string `json:"subject"` + CreatedByUserID int64 `json:"created_by_user_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + LastReadAt pgtype.Timestamptz `json:"last_read_at"` + UnreadCount int64 `json:"unread_count"` +} + +func (q *Queries) GetMessageThreadForUser(ctx context.Context, arg GetMessageThreadForUserParams) (GetMessageThreadForUserRow, error) { + row := q.db.QueryRow(ctx, getMessageThreadForUser, arg.ID, arg.SenderUserID) + var i GetMessageThreadForUserRow + err := row.Scan( + &i.ID, + &i.Subject, + &i.CreatedByUserID, + &i.CreatedAt, + &i.UpdatedAt, + &i.LastReadAt, + &i.UnreadCount, + ) + return i, err +} + +const listMessageRecipientsForUser = `-- name: ListMessageRecipientsForUser :many +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + p.preferred_name, + p.profile_icon_url, + p.headline +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.id <> $1 + AND u.is_active = TRUE + AND ( + EXISTS ( + SELECT 1 + FROM classrooms c + JOIN classroom_students cs ON cs.classroom_id = c.id + WHERE c.teacher_id = u.id + AND cs.student_id = $1 + ) + OR EXISTS ( + SELECT 1 + FROM classrooms c + JOIN classroom_students cs ON cs.classroom_id = c.id + WHERE c.teacher_id = $1 + AND cs.student_id = u.id + ) + ) +ORDER BY COALESCE(NULLIF(p.preferred_name, ''), u.full_name) ASC, u.id ASC +` + +type ListMessageRecipientsForUserRow struct { + UserID int64 `json:"user_id"` + UserEmail string `json:"user_email"` + UserRole UserRole `json:"user_role"` + UserFullName string `json:"user_full_name"` + PreferredName pgtype.Text `json:"preferred_name"` + ProfileIconUrl pgtype.Text `json:"profile_icon_url"` + Headline pgtype.Text `json:"headline"` +} + +func (q *Queries) ListMessageRecipientsForUser(ctx context.Context, id int64) ([]ListMessageRecipientsForUserRow, error) { + rows, err := q.db.Query(ctx, listMessageRecipientsForUser, id) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListMessageRecipientsForUserRow{} + for rows.Next() { + var i ListMessageRecipientsForUserRow + if err := rows.Scan( + &i.UserID, + &i.UserEmail, + &i.UserRole, + &i.UserFullName, + &i.PreferredName, + &i.ProfileIconUrl, + &i.Headline, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listMessageThreadParticipantsForUser = `-- name: ListMessageThreadParticipantsForUser :many +SELECT + mtp.thread_id, + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + p.preferred_name, + p.profile_icon_url, + p.headline, + mtp.joined_at, + mtp.last_read_at, + mtp.archived_at +FROM message_thread_participants mtp +JOIN users u ON u.id = mtp.user_id +LEFT JOIN profiles p ON p.user_id = u.id +WHERE mtp.thread_id IN ( + SELECT participant.thread_id + FROM message_thread_participants participant + WHERE participant.user_id = $1 + AND participant.archived_at IS NULL +) +ORDER BY mtp.thread_id ASC, COALESCE(NULLIF(p.preferred_name, ''), u.full_name) ASC, u.id ASC +` + +type ListMessageThreadParticipantsForUserRow struct { + ThreadID int64 `json:"thread_id"` + UserID int64 `json:"user_id"` + UserEmail string `json:"user_email"` + UserRole UserRole `json:"user_role"` + UserFullName string `json:"user_full_name"` + PreferredName pgtype.Text `json:"preferred_name"` + ProfileIconUrl pgtype.Text `json:"profile_icon_url"` + Headline pgtype.Text `json:"headline"` + JoinedAt pgtype.Timestamptz `json:"joined_at"` + LastReadAt pgtype.Timestamptz `json:"last_read_at"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` +} + +func (q *Queries) ListMessageThreadParticipantsForUser(ctx context.Context, userID int64) ([]ListMessageThreadParticipantsForUserRow, error) { + rows, err := q.db.Query(ctx, listMessageThreadParticipantsForUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListMessageThreadParticipantsForUserRow{} + for rows.Next() { + var i ListMessageThreadParticipantsForUserRow + if err := rows.Scan( + &i.ThreadID, + &i.UserID, + &i.UserEmail, + &i.UserRole, + &i.UserFullName, + &i.PreferredName, + &i.ProfileIconUrl, + &i.Headline, + &i.JoinedAt, + &i.LastReadAt, + &i.ArchivedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listMessageThreadsForUser = `-- name: ListMessageThreadsForUser :many +SELECT + t.id AS thread_id, + t.subject, + t.created_by_user_id, + t.created_at AS thread_created_at, + t.updated_at AS thread_updated_at, + COALESCE(last_message.id, 0)::bigint AS last_message_id, + COALESCE(last_message.body, '') AS last_message_body, + last_message.created_at AS last_message_created_at, + COALESCE(last_message.sender_user_id, 0)::bigint AS last_message_sender_user_id, + sender.full_name AS last_message_sender_full_name, + sender_profile.preferred_name AS last_message_sender_preferred_name, + sender_profile.profile_icon_url AS last_message_sender_profile_icon_url, + COALESCE(( + SELECT COUNT(*)::bigint + FROM messages unread + WHERE unread.thread_id = t.id + AND unread.sender_user_id <> $1 + AND (participant.last_read_at IS NULL OR unread.created_at > participant.last_read_at) + ), 0)::bigint AS unread_count +FROM message_thread_participants participant +JOIN message_threads t ON t.id = participant.thread_id +LEFT JOIN LATERAL ( + SELECT m.id, m.body, m.created_at, m.sender_user_id + FROM messages m + WHERE m.thread_id = t.id + ORDER BY m.created_at DESC, m.id DESC + LIMIT 1 +) AS last_message ON TRUE +LEFT JOIN users sender ON sender.id = last_message.sender_user_id +LEFT JOIN profiles sender_profile ON sender_profile.user_id = sender.id +WHERE participant.user_id = $1 + AND participant.archived_at IS NULL +ORDER BY COALESCE(last_message.created_at, t.updated_at) DESC, t.id DESC +` + +type ListMessageThreadsForUserRow struct { + ThreadID int64 `json:"thread_id"` + Subject string `json:"subject"` + CreatedByUserID int64 `json:"created_by_user_id"` + ThreadCreatedAt pgtype.Timestamptz `json:"thread_created_at"` + ThreadUpdatedAt pgtype.Timestamptz `json:"thread_updated_at"` + LastMessageID int64 `json:"last_message_id"` + LastMessageBody string `json:"last_message_body"` + LastMessageCreatedAt pgtype.Timestamptz `json:"last_message_created_at"` + LastMessageSenderUserID int64 `json:"last_message_sender_user_id"` + LastMessageSenderFullName pgtype.Text `json:"last_message_sender_full_name"` + LastMessageSenderPreferredName pgtype.Text `json:"last_message_sender_preferred_name"` + LastMessageSenderProfileIconUrl pgtype.Text `json:"last_message_sender_profile_icon_url"` + UnreadCount int64 `json:"unread_count"` +} + +func (q *Queries) ListMessageThreadsForUser(ctx context.Context, senderUserID int64) ([]ListMessageThreadsForUserRow, error) { + rows, err := q.db.Query(ctx, listMessageThreadsForUser, senderUserID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListMessageThreadsForUserRow{} + for rows.Next() { + var i ListMessageThreadsForUserRow + if err := rows.Scan( + &i.ThreadID, + &i.Subject, + &i.CreatedByUserID, + &i.ThreadCreatedAt, + &i.ThreadUpdatedAt, + &i.LastMessageID, + &i.LastMessageBody, + &i.LastMessageCreatedAt, + &i.LastMessageSenderUserID, + &i.LastMessageSenderFullName, + &i.LastMessageSenderPreferredName, + &i.LastMessageSenderProfileIconUrl, + &i.UnreadCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listMessagesForThreadForUser = `-- name: ListMessagesForThreadForUser :many +SELECT + m.id, + m.thread_id, + m.sender_user_id, + m.body, + m.created_at, + m.updated_at, + sender.email AS sender_email, + sender.role AS sender_role, + sender.full_name AS sender_full_name, + sender_profile.preferred_name AS sender_preferred_name, + sender_profile.profile_icon_url AS sender_profile_icon_url, + sender_profile.headline AS sender_headline +FROM messages m +JOIN message_thread_participants participant ON participant.thread_id = m.thread_id +JOIN users sender ON sender.id = m.sender_user_id +LEFT JOIN profiles sender_profile ON sender_profile.user_id = sender.id +WHERE m.thread_id = $1 + AND participant.user_id = $2 + AND participant.archived_at IS NULL +ORDER BY m.created_at ASC, m.id ASC +` + +type ListMessagesForThreadForUserParams struct { + ThreadID int64 `json:"thread_id"` + UserID int64 `json:"user_id"` +} + +type ListMessagesForThreadForUserRow struct { + ID int64 `json:"id"` + ThreadID int64 `json:"thread_id"` + SenderUserID int64 `json:"sender_user_id"` + Body string `json:"body"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + SenderEmail string `json:"sender_email"` + SenderRole UserRole `json:"sender_role"` + SenderFullName string `json:"sender_full_name"` + SenderPreferredName pgtype.Text `json:"sender_preferred_name"` + SenderProfileIconUrl pgtype.Text `json:"sender_profile_icon_url"` + SenderHeadline pgtype.Text `json:"sender_headline"` +} + +func (q *Queries) ListMessagesForThreadForUser(ctx context.Context, arg ListMessagesForThreadForUserParams) ([]ListMessagesForThreadForUserRow, error) { + rows, err := q.db.Query(ctx, listMessagesForThreadForUser, arg.ThreadID, arg.UserID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListMessagesForThreadForUserRow{} + for rows.Next() { + var i ListMessagesForThreadForUserRow + if err := rows.Scan( + &i.ID, + &i.ThreadID, + &i.SenderUserID, + &i.Body, + &i.CreatedAt, + &i.UpdatedAt, + &i.SenderEmail, + &i.SenderRole, + &i.SenderFullName, + &i.SenderPreferredName, + &i.SenderProfileIconUrl, + &i.SenderHeadline, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listParticipantsForThreadForUser = `-- name: ListParticipantsForThreadForUser :many +SELECT + mtp.thread_id, + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + p.preferred_name, + p.profile_icon_url, + p.headline, + mtp.joined_at, + mtp.last_read_at, + mtp.archived_at +FROM message_thread_participants mtp +JOIN users u ON u.id = mtp.user_id +LEFT JOIN profiles p ON p.user_id = u.id +WHERE mtp.thread_id = $1 + AND EXISTS ( + SELECT 1 + FROM message_thread_participants participant + WHERE participant.thread_id = mtp.thread_id + AND participant.user_id = $2 + AND participant.archived_at IS NULL + ) +ORDER BY COALESCE(NULLIF(p.preferred_name, ''), u.full_name) ASC, u.id ASC +` + +type ListParticipantsForThreadForUserParams struct { + ThreadID int64 `json:"thread_id"` + UserID int64 `json:"user_id"` +} + +type ListParticipantsForThreadForUserRow struct { + ThreadID int64 `json:"thread_id"` + UserID int64 `json:"user_id"` + UserEmail string `json:"user_email"` + UserRole UserRole `json:"user_role"` + UserFullName string `json:"user_full_name"` + PreferredName pgtype.Text `json:"preferred_name"` + ProfileIconUrl pgtype.Text `json:"profile_icon_url"` + Headline pgtype.Text `json:"headline"` + JoinedAt pgtype.Timestamptz `json:"joined_at"` + LastReadAt pgtype.Timestamptz `json:"last_read_at"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` +} + +func (q *Queries) ListParticipantsForThreadForUser(ctx context.Context, arg ListParticipantsForThreadForUserParams) ([]ListParticipantsForThreadForUserRow, error) { + rows, err := q.db.Query(ctx, listParticipantsForThreadForUser, arg.ThreadID, arg.UserID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListParticipantsForThreadForUserRow{} + for rows.Next() { + var i ListParticipantsForThreadForUserRow + if err := rows.Scan( + &i.ThreadID, + &i.UserID, + &i.UserEmail, + &i.UserRole, + &i.UserFullName, + &i.PreferredName, + &i.ProfileIconUrl, + &i.Headline, + &i.JoinedAt, + &i.LastReadAt, + &i.ArchivedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markMessageThreadRead = `-- name: MarkMessageThreadRead :one +UPDATE message_thread_participants +SET last_read_at = COALESCE((SELECT MAX(m.created_at) FROM messages m WHERE m.thread_id = $1), NOW()) +WHERE message_thread_participants.thread_id = $1 + AND message_thread_participants.user_id = $2 +RETURNING thread_id, user_id, joined_at, last_read_at, archived_at +` + +type MarkMessageThreadReadParams struct { + ThreadID int64 `json:"thread_id"` + UserID int64 `json:"user_id"` +} + +func (q *Queries) MarkMessageThreadRead(ctx context.Context, arg MarkMessageThreadReadParams) (MessageThreadParticipant, error) { + row := q.db.QueryRow(ctx, markMessageThreadRead, arg.ThreadID, arg.UserID) + var i MessageThreadParticipant + err := row.Scan( + &i.ThreadID, + &i.UserID, + &i.JoinedAt, + &i.LastReadAt, + &i.ArchivedAt, + ) + return i, err +} + +const touchMessageThread = `-- name: TouchMessageThread :exec +UPDATE message_threads +SET updated_at = NOW() +WHERE id = $1 +` + +func (q *Queries) TouchMessageThread(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, touchMessageThread, id) + return err +} + +const updateMessageThreadSubject = `-- name: UpdateMessageThreadSubject :one +UPDATE message_threads +SET subject = $1, + updated_at = NOW() +WHERE id = $2 +RETURNING id, created_by_user_id, subject, created_at, updated_at +` + +type UpdateMessageThreadSubjectParams struct { + Subject string `json:"subject"` + ThreadID int64 `json:"thread_id"` +} + +func (q *Queries) UpdateMessageThreadSubject(ctx context.Context, arg UpdateMessageThreadSubjectParams) (MessageThread, error) { + row := q.db.QueryRow(ctx, updateMessageThreadSubject, arg.Subject, arg.ThreadID) + var i MessageThread + err := row.Scan( + &i.ID, + &i.CreatedByUserID, + &i.Subject, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateThreadMessageBody = `-- name: UpdateThreadMessageBody :one +UPDATE messages +SET body = $1, + updated_at = NOW() +WHERE id = $2 + AND thread_id = $3 + AND sender_user_id = $4 +RETURNING id, thread_id, sender_user_id, body, created_at, updated_at +` + +type UpdateThreadMessageBodyParams struct { + Body string `json:"body"` + MessageID int64 `json:"message_id"` + ThreadID int64 `json:"thread_id"` + UserID int64 `json:"user_id"` +} + +func (q *Queries) UpdateThreadMessageBody(ctx context.Context, arg UpdateThreadMessageBodyParams) (Message, error) { + row := q.db.QueryRow(ctx, updateThreadMessageBody, + arg.Body, + arg.MessageID, + arg.ThreadID, + arg.UserID, + ) + var i Message + err := row.Scan( + &i.ID, + &i.ThreadID, + &i.SenderUserID, + &i.Body, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/Backend/internal/sqlc/models.go b/Backend/internal/sqlc/models.go new file mode 100644 index 0000000..6362299 --- /dev/null +++ b/Backend/internal/sqlc/models.go @@ -0,0 +1,526 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlc + +import ( + "database/sql/driver" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" +) + +type AnswerStatus string + +const ( + AnswerStatusNotStarted AnswerStatus = "not_started" + AnswerStatusInProgress AnswerStatus = "in_progress" + AnswerStatusSubmitted AnswerStatus = "submitted" + AnswerStatusReviewed AnswerStatus = "reviewed" +) + +func (e *AnswerStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = AnswerStatus(s) + case string: + *e = AnswerStatus(s) + default: + return fmt.Errorf("unsupported scan type for AnswerStatus: %T", src) + } + return nil +} + +type NullAnswerStatus struct { + AnswerStatus AnswerStatus `json:"answer_status"` + Valid bool `json:"valid"` // Valid is true if AnswerStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullAnswerStatus) Scan(value interface{}) error { + if value == nil { + ns.AnswerStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.AnswerStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullAnswerStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.AnswerStatus), nil +} + +type AssignmentNextStepOutcome string + +const ( + AssignmentNextStepOutcomeRedo AssignmentNextStepOutcome = "redo" + AssignmentNextStepOutcomeAccept AssignmentNextStepOutcome = "accept" + AssignmentNextStepOutcomeSupport AssignmentNextStepOutcome = "support" +) + +func (e *AssignmentNextStepOutcome) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = AssignmentNextStepOutcome(s) + case string: + *e = AssignmentNextStepOutcome(s) + default: + return fmt.Errorf("unsupported scan type for AssignmentNextStepOutcome: %T", src) + } + return nil +} + +type NullAssignmentNextStepOutcome struct { + AssignmentNextStepOutcome AssignmentNextStepOutcome `json:"assignment_next_step_outcome"` + Valid bool `json:"valid"` // Valid is true if AssignmentNextStepOutcome is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullAssignmentNextStepOutcome) Scan(value interface{}) error { + if value == nil { + ns.AssignmentNextStepOutcome, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.AssignmentNextStepOutcome.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullAssignmentNextStepOutcome) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.AssignmentNextStepOutcome), nil +} + +type AssignmentPassStatus string + +const ( + AssignmentPassStatusPending AssignmentPassStatus = "pending" + AssignmentPassStatusPass AssignmentPassStatus = "pass" + AssignmentPassStatusNoPass AssignmentPassStatus = "no_pass" +) + +func (e *AssignmentPassStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = AssignmentPassStatus(s) + case string: + *e = AssignmentPassStatus(s) + default: + return fmt.Errorf("unsupported scan type for AssignmentPassStatus: %T", src) + } + return nil +} + +type NullAssignmentPassStatus struct { + AssignmentPassStatus AssignmentPassStatus `json:"assignment_pass_status"` + Valid bool `json:"valid"` // Valid is true if AssignmentPassStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullAssignmentPassStatus) Scan(value interface{}) error { + if value == nil { + ns.AssignmentPassStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.AssignmentPassStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullAssignmentPassStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.AssignmentPassStatus), nil +} + +type AssignmentStatus string + +const ( + AssignmentStatusDraft AssignmentStatus = "draft" + AssignmentStatusAssigned AssignmentStatus = "assigned" + AssignmentStatusClosed AssignmentStatus = "closed" +) + +func (e *AssignmentStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = AssignmentStatus(s) + case string: + *e = AssignmentStatus(s) + default: + return fmt.Errorf("unsupported scan type for AssignmentStatus: %T", src) + } + return nil +} + +type NullAssignmentStatus struct { + AssignmentStatus AssignmentStatus `json:"assignment_status"` + Valid bool `json:"valid"` // Valid is true if AssignmentStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullAssignmentStatus) Scan(value interface{}) error { + if value == nil { + ns.AssignmentStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.AssignmentStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullAssignmentStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.AssignmentStatus), nil +} + +type QuestionDifficulty string + +const ( + QuestionDifficultyEasy QuestionDifficulty = "easy" + QuestionDifficultyMedium QuestionDifficulty = "medium" + QuestionDifficultyHard QuestionDifficulty = "hard" +) + +func (e *QuestionDifficulty) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = QuestionDifficulty(s) + case string: + *e = QuestionDifficulty(s) + default: + return fmt.Errorf("unsupported scan type for QuestionDifficulty: %T", src) + } + return nil +} + +type NullQuestionDifficulty struct { + QuestionDifficulty QuestionDifficulty `json:"question_difficulty"` + Valid bool `json:"valid"` // Valid is true if QuestionDifficulty is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullQuestionDifficulty) Scan(value interface{}) error { + if value == nil { + ns.QuestionDifficulty, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.QuestionDifficulty.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullQuestionDifficulty) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.QuestionDifficulty), nil +} + +type QuestionStatus string + +const ( + QuestionStatusDraft QuestionStatus = "draft" + QuestionStatusPublished QuestionStatus = "published" + QuestionStatusArchived QuestionStatus = "archived" +) + +func (e *QuestionStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = QuestionStatus(s) + case string: + *e = QuestionStatus(s) + default: + return fmt.Errorf("unsupported scan type for QuestionStatus: %T", src) + } + return nil +} + +type NullQuestionStatus struct { + QuestionStatus QuestionStatus `json:"question_status"` + Valid bool `json:"valid"` // Valid is true if QuestionStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullQuestionStatus) Scan(value interface{}) error { + if value == nil { + ns.QuestionStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.QuestionStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullQuestionStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.QuestionStatus), nil +} + +type QuestionTopic string + +const ( + QuestionTopicPlaceValue QuestionTopic = "place_value" + QuestionTopicArithmetic QuestionTopic = "arithmetic" + QuestionTopicNegativeNumbers QuestionTopic = "negative_numbers" + QuestionTopicBidmas QuestionTopic = "bidmas" + QuestionTopicFractions QuestionTopic = "fractions" + QuestionTopicAlgebra QuestionTopic = "algebra" + QuestionTopicGeometry QuestionTopic = "geometry" + QuestionTopicData QuestionTopic = "data" +) + +func (e *QuestionTopic) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = QuestionTopic(s) + case string: + *e = QuestionTopic(s) + default: + return fmt.Errorf("unsupported scan type for QuestionTopic: %T", src) + } + return nil +} + +type NullQuestionTopic struct { + QuestionTopic QuestionTopic `json:"question_topic"` + Valid bool `json:"valid"` // Valid is true if QuestionTopic is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullQuestionTopic) Scan(value interface{}) error { + if value == nil { + ns.QuestionTopic, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.QuestionTopic.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullQuestionTopic) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.QuestionTopic), nil +} + +type UserRole string + +const ( + UserRoleStudent UserRole = "student" + UserRoleTeacher UserRole = "teacher" +) + +func (e *UserRole) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = UserRole(s) + case string: + *e = UserRole(s) + default: + return fmt.Errorf("unsupported scan type for UserRole: %T", src) + } + return nil +} + +type NullUserRole struct { + UserRole UserRole `json:"user_role"` + Valid bool `json:"valid"` // Valid is true if UserRole is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullUserRole) Scan(value interface{}) error { + if value == nil { + ns.UserRole, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.UserRole.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullUserRole) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.UserRole), nil +} + +type Assignment struct { + ID int64 `json:"id"` + ClassroomID int64 `json:"classroom_id"` + TeacherID int64 `json:"teacher_id"` + Title string `json:"title"` + Instructions pgtype.Text `json:"instructions"` + Status AssignmentStatus `json:"status"` + DueAt pgtype.Timestamptz `json:"due_at"` + PublishedAt pgtype.Timestamptz `json:"published_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + PassThreshold pgtype.Numeric `json:"pass_threshold"` +} + +type AssignmentAssignee struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + AssignedAt pgtype.Timestamptz `json:"assigned_at"` + AiFeedback pgtype.Text `json:"ai_feedback"` + TeacherFeedback pgtype.Text `json:"teacher_feedback"` + OverallScore pgtype.Numeric `json:"overall_score"` + PassThreshold pgtype.Numeric `json:"pass_threshold"` + PassStatus AssignmentPassStatus `json:"pass_status"` + PassStatusOverride NullAssignmentPassStatus `json:"pass_status_override"` + NextStepOutcome NullAssignmentNextStepOutcome `json:"next_step_outcome"` + RedoPlan pgtype.Text `json:"redo_plan"` + RedoPlanGeneratedAt pgtype.Timestamptz `json:"redo_plan_generated_at"` +} + +type AssignmentQuestion struct { + AssignmentID int64 `json:"assignment_id"` + QuestionID int64 `json:"question_id"` + Position int32 `json:"position"` +} + +type AssignmentStudentQuestion struct { + ID int64 `json:"id"` + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + QuestionID int64 `json:"question_id"` + Position int32 `json:"position"` + SourceBucket string `json:"source_bucket"` + SourceTopic NullQuestionTopic `json:"source_topic"` + SourceDifficulty NullQuestionDifficulty `json:"source_difficulty"` + GeneratorSeed pgtype.Int8 `json:"generator_seed"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Classroom struct { + ID int64 `json:"id"` + TeacherID int64 `json:"teacher_id"` + Name string `json:"name"` + Code pgtype.Text `json:"code"` + Description pgtype.Text `json:"description"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type ClassroomStudent struct { + ClassroomID int64 `json:"classroom_id"` + StudentID int64 `json:"student_id"` + JoinedAt pgtype.Timestamptz `json:"joined_at"` +} + +type Message struct { + ID int64 `json:"id"` + ThreadID int64 `json:"thread_id"` + SenderUserID int64 `json:"sender_user_id"` + Body string `json:"body"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type MessageThread struct { + ID int64 `json:"id"` + CreatedByUserID int64 `json:"created_by_user_id"` + Subject string `json:"subject"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type MessageThreadParticipant struct { + ThreadID int64 `json:"thread_id"` + UserID int64 `json:"user_id"` + JoinedAt pgtype.Timestamptz `json:"joined_at"` + LastReadAt pgtype.Timestamptz `json:"last_read_at"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` +} + +type Profile struct { + UserID int64 `json:"user_id"` + PreferredName pgtype.Text `json:"preferred_name"` + ProfileIconUrl pgtype.Text `json:"profile_icon_url"` + Headline pgtype.Text `json:"headline"` + Bio pgtype.Text `json:"bio"` + Timezone pgtype.Text `json:"timezone"` + Locale pgtype.Text `json:"locale"` + GradeLevel pgtype.Text `json:"grade_level"` + LearningGoal pgtype.Text `json:"learning_goal"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type Question struct { + ID int64 `json:"id"` + AuthorTeacherID int64 `json:"author_teacher_id"` + Title string `json:"title"` + Prompt string `json:"prompt"` + Subject pgtype.Text `json:"subject"` + Source pgtype.Text `json:"source"` + Status QuestionStatus `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + CorrectAnswer pgtype.Text `json:"correct_answer"` + Topic NullQuestionTopic `json:"topic"` + Difficulty NullQuestionDifficulty `json:"difficulty"` +} + +type QuestionTag struct { + QuestionID int64 `json:"question_id"` + TagID int64 `json:"tag_id"` +} + +type StudentAnswer struct { + ID int64 `json:"id"` + AssignmentID int64 `json:"assignment_id"` + QuestionID int64 `json:"question_id"` + StudentID int64 `json:"student_id"` + AnswerText pgtype.Text `json:"answer_text"` + AiFeedback pgtype.Text `json:"ai_feedback"` + TeacherFeedback pgtype.Text `json:"teacher_feedback"` + Status AnswerStatus `json:"status"` + SubmittedAt pgtype.Timestamptz `json:"submitted_at"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + SolveMode string `json:"solve_mode"` + WorkingSteps pgtype.Text `json:"working_steps"` + IsCorrect pgtype.Bool `json:"is_correct"` + ReviewNeedsAttention bool `json:"review_needs_attention"` + ReviewIssueReason pgtype.Text `json:"review_issue_reason"` + ReviewCorrectnessScore pgtype.Numeric `json:"review_correctness_score"` + ReviewUnderstandingScore pgtype.Numeric `json:"review_understanding_score"` + ReviewQuestionScore pgtype.Numeric `json:"review_question_score"` + ReviewConfidence pgtype.Numeric `json:"review_confidence"` + ReviewTags []string `json:"review_tags"` +} + +type Tag struct { + ID int64 `json:"id"` + Name string `json:"name"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type User struct { + ID int64 `json:"id"` + Email string `json:"email"` + PasswordHash pgtype.Text `json:"password_hash"` + Role UserRole `json:"role"` + FullName string `json:"full_name"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} diff --git a/Backend/internal/sqlc/questions.sql.go b/Backend/internal/sqlc/questions.sql.go new file mode 100644 index 0000000..a330ec9 --- /dev/null +++ b/Backend/internal/sqlc/questions.sql.go @@ -0,0 +1,206 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: questions.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const attachTagToQuestion = `-- name: AttachTagToQuestion :exec +INSERT INTO question_tags ( + question_id, + tag_id +) VALUES ( + $1, + $2 +) +ON CONFLICT (question_id, tag_id) DO NOTHING +` + +type AttachTagToQuestionParams struct { + QuestionID int64 `json:"question_id"` + TagID int64 `json:"tag_id"` +} + +func (q *Queries) AttachTagToQuestion(ctx context.Context, arg AttachTagToQuestionParams) error { + _, err := q.db.Exec(ctx, attachTagToQuestion, arg.QuestionID, arg.TagID) + return err +} + +const createQuestion = `-- name: CreateQuestion :one +INSERT INTO questions ( + author_teacher_id, + title, + prompt, + topic, + subject, + difficulty, + source, + status, + correct_answer +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9 +) +RETURNING id, author_teacher_id, title, prompt, subject, source, status, created_at, updated_at, correct_answer, topic, difficulty +` + +type CreateQuestionParams struct { + AuthorTeacherID int64 `json:"author_teacher_id"` + Title string `json:"title"` + Prompt string `json:"prompt"` + Topic NullQuestionTopic `json:"topic"` + Subject pgtype.Text `json:"subject"` + Difficulty NullQuestionDifficulty `json:"difficulty"` + Source pgtype.Text `json:"source"` + Status QuestionStatus `json:"status"` + CorrectAnswer pgtype.Text `json:"correct_answer"` +} + +func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams) (Question, error) { + row := q.db.QueryRow(ctx, createQuestion, + arg.AuthorTeacherID, + arg.Title, + arg.Prompt, + arg.Topic, + arg.Subject, + arg.Difficulty, + arg.Source, + arg.Status, + arg.CorrectAnswer, + ) + var i Question + err := row.Scan( + &i.ID, + &i.AuthorTeacherID, + &i.Title, + &i.Prompt, + &i.Subject, + &i.Source, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + &i.CorrectAnswer, + &i.Topic, + &i.Difficulty, + ) + return i, err +} + +const createTag = `-- name: CreateTag :one +INSERT INTO tags (name) +VALUES ($1) +ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name +RETURNING id, name, created_at +` + +func (q *Queries) CreateTag(ctx context.Context, name string) (Tag, error) { + row := q.db.QueryRow(ctx, createTag, name) + var i Tag + err := row.Scan(&i.ID, &i.Name, &i.CreatedAt) + return i, err +} + +const getQuestionByID = `-- name: GetQuestionByID :one +SELECT id, author_teacher_id, title, prompt, subject, source, status, created_at, updated_at, correct_answer, topic, difficulty +FROM questions +WHERE id = $1 +` + +func (q *Queries) GetQuestionByID(ctx context.Context, id int64) (Question, error) { + row := q.db.QueryRow(ctx, getQuestionByID, id) + var i Question + err := row.Scan( + &i.ID, + &i.AuthorTeacherID, + &i.Title, + &i.Prompt, + &i.Subject, + &i.Source, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + &i.CorrectAnswer, + &i.Topic, + &i.Difficulty, + ) + return i, err +} + +const listQuestionsByTeacher = `-- name: ListQuestionsByTeacher :many +SELECT id, author_teacher_id, title, prompt, subject, source, status, created_at, updated_at, correct_answer, topic, difficulty +FROM questions +WHERE author_teacher_id = $1 +ORDER BY created_at DESC +` + +func (q *Queries) ListQuestionsByTeacher(ctx context.Context, authorTeacherID int64) ([]Question, error) { + rows, err := q.db.Query(ctx, listQuestionsByTeacher, authorTeacherID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Question{} + for rows.Next() { + var i Question + if err := rows.Scan( + &i.ID, + &i.AuthorTeacherID, + &i.Title, + &i.Prompt, + &i.Subject, + &i.Source, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + &i.CorrectAnswer, + &i.Topic, + &i.Difficulty, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTags = `-- name: ListTags :many +SELECT id, name, created_at +FROM tags +ORDER BY name ASC +` + +func (q *Queries) ListTags(ctx context.Context) ([]Tag, error) { + rows, err := q.db.Query(ctx, listTags) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Tag{} + for rows.Next() { + var i Tag + if err := rows.Scan(&i.ID, &i.Name, &i.CreatedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/Backend/internal/sqlc/student_answers.sql.go b/Backend/internal/sqlc/student_answers.sql.go new file mode 100644 index 0000000..2df46a8 --- /dev/null +++ b/Backend/internal/sqlc/student_answers.sql.go @@ -0,0 +1,649 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: student_answers.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const listAnswersForAssignment = `-- name: ListAnswersForAssignment :many +SELECT id, assignment_id, question_id, student_id, answer_text, ai_feedback, teacher_feedback, status, submitted_at, reviewed_at, created_at, updated_at, solve_mode, working_steps, is_correct, review_needs_attention, review_issue_reason, review_correctness_score, review_understanding_score, review_question_score, review_confidence, review_tags +FROM student_answers +WHERE assignment_id = $1 +ORDER BY created_at ASC +` + +func (q *Queries) ListAnswersForAssignment(ctx context.Context, assignmentID int64) ([]StudentAnswer, error) { + rows, err := q.db.Query(ctx, listAnswersForAssignment, assignmentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []StudentAnswer{} + for rows.Next() { + var i StudentAnswer + if err := rows.Scan( + &i.ID, + &i.AssignmentID, + &i.QuestionID, + &i.StudentID, + &i.AnswerText, + &i.AiFeedback, + &i.TeacherFeedback, + &i.Status, + &i.SubmittedAt, + &i.ReviewedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.SolveMode, + &i.WorkingSteps, + &i.IsCorrect, + &i.ReviewNeedsAttention, + &i.ReviewIssueReason, + &i.ReviewCorrectnessScore, + &i.ReviewUnderstandingScore, + &i.ReviewQuestionScore, + &i.ReviewConfidence, + &i.ReviewTags, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAnswersForStudent = `-- name: ListAnswersForStudent :many +SELECT id, assignment_id, question_id, student_id, answer_text, ai_feedback, teacher_feedback, status, submitted_at, reviewed_at, created_at, updated_at, solve_mode, working_steps, is_correct, review_needs_attention, review_issue_reason, review_correctness_score, review_understanding_score, review_question_score, review_confidence, review_tags +FROM student_answers +WHERE student_id = $1 +ORDER BY created_at DESC +` + +func (q *Queries) ListAnswersForStudent(ctx context.Context, studentID int64) ([]StudentAnswer, error) { + rows, err := q.db.Query(ctx, listAnswersForStudent, studentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []StudentAnswer{} + for rows.Next() { + var i StudentAnswer + if err := rows.Scan( + &i.ID, + &i.AssignmentID, + &i.QuestionID, + &i.StudentID, + &i.AnswerText, + &i.AiFeedback, + &i.TeacherFeedback, + &i.Status, + &i.SubmittedAt, + &i.ReviewedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.SolveMode, + &i.WorkingSteps, + &i.IsCorrect, + &i.ReviewNeedsAttention, + &i.ReviewIssueReason, + &i.ReviewCorrectnessScore, + &i.ReviewUnderstandingScore, + &i.ReviewQuestionScore, + &i.ReviewConfidence, + &i.ReviewTags, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listQuestionDetailsForAssignmentStudent = `-- name: ListQuestionDetailsForAssignmentStudent :many +WITH student_question_set AS ( + SELECT + asq.assignment_id, + asq.question_id, + asq.position + FROM assignment_student_questions asq + WHERE asq.assignment_id = $1 + AND asq.student_id = $2 +), +selected_questions AS ( + SELECT + sq.assignment_id, + sq.question_id, + sq.position + FROM student_question_set sq + UNION ALL + SELECT + aq.assignment_id, + aq.question_id, + aq.position + FROM assignment_questions aq + WHERE aq.assignment_id = $1 + AND NOT EXISTS (SELECT 1 FROM student_question_set) +) +SELECT + aq.assignment_id, + aq.question_id, + aq.position, + q.title, + q.prompt, + q.subject, + q.source, + COALESCE( + ARRAY( + SELECT t.name + FROM question_tags qt + JOIN tags t ON t.id = qt.tag_id + WHERE qt.question_id = aq.question_id + ORDER BY t.name ASC + ), + ARRAY[]::TEXT[] + )::TEXT[] AS question_tags, + q.status AS question_status, + q.correct_answer, + aa.ai_feedback AS assignment_ai_feedback, + aa.teacher_feedback AS assignment_teacher_feedback, + review_summary.overall_score, + a.pass_threshold, + aa.next_step_outcome, + aa.pass_status_override, + COALESCE( + aa.pass_status_override, + CASE + WHEN review_summary.overall_score IS NULL THEN 'pending'::assignment_pass_status + WHEN review_summary.overall_score >= a.pass_threshold THEN 'pass'::assignment_pass_status + ELSE 'no_pass'::assignment_pass_status + END + ) AS pass_status, + sa.id AS answer_id, + sa.student_id, + sa.answer_text, + sa.solve_mode, + sa.working_steps, + sa.is_correct, + sa.ai_feedback, + sa.teacher_feedback, + sa.status AS answer_status, + sa.review_needs_attention, + sa.review_issue_reason, + sa.review_correctness_score, + sa.review_understanding_score, + sa.review_question_score, + sa.review_confidence, + sa.review_tags, + sa.submitted_at, + sa.reviewed_at, + sa.created_at AS answer_created_at, + sa.updated_at AS answer_updated_at + FROM selected_questions aq + JOIN assignments a ON a.id = aq.assignment_id + JOIN questions q ON q.id = aq.question_id + LEFT JOIN assignment_assignees aa + ON aa.assignment_id = aq.assignment_id + AND aa.student_id = $2 + LEFT JOIN LATERAL ( + SELECT CASE + WHEN COUNT(sa2.id) = 0 THEN NULL::NUMERIC(5,2) + ELSE ROUND((AVG( + CASE + WHEN sa2.is_correct IS NULL THEN COALESCE(sa2.review_understanding_score, 0)::NUMERIC + ELSE ( + ((CASE WHEN sa2.is_correct THEN 1 ELSE 0 END)::NUMERIC) + COALESCE(sa2.review_understanding_score, 0)::NUMERIC + ) / 2 + END + ) * 10)::NUMERIC, 2)::NUMERIC(5,2) + END AS overall_score + FROM selected_questions aq2 + LEFT JOIN student_answers sa2 + ON sa2.assignment_id = aq2.assignment_id + AND sa2.question_id = aq2.question_id + AND sa2.student_id = $2 + WHERE aq2.assignment_id = aq.assignment_id +) review_summary ON TRUE +LEFT JOIN student_answers sa + ON sa.assignment_id = aq.assignment_id + AND sa.question_id = aq.question_id + AND sa.student_id = $2 +WHERE aq.assignment_id = $1 +ORDER BY aq.position ASC, aq.question_id ASC +` + +type ListQuestionDetailsForAssignmentStudentParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` +} + +type ListQuestionDetailsForAssignmentStudentRow struct { + AssignmentID int64 `json:"assignment_id"` + QuestionID int64 `json:"question_id"` + Position int32 `json:"position"` + Title string `json:"title"` + Prompt string `json:"prompt"` + Subject pgtype.Text `json:"subject"` + Source pgtype.Text `json:"source"` + QuestionTags []string `json:"question_tags"` + QuestionStatus QuestionStatus `json:"question_status"` + CorrectAnswer pgtype.Text `json:"correct_answer"` + AssignmentAiFeedback pgtype.Text `json:"assignment_ai_feedback"` + AssignmentTeacherFeedback pgtype.Text `json:"assignment_teacher_feedback"` + OverallScore pgtype.Numeric `json:"overall_score"` + PassThreshold pgtype.Numeric `json:"pass_threshold"` + NextStepOutcome NullAssignmentNextStepOutcome `json:"next_step_outcome"` + PassStatusOverride NullAssignmentPassStatus `json:"pass_status_override"` + PassStatus NullAssignmentPassStatus `json:"pass_status"` + AnswerID pgtype.Int8 `json:"answer_id"` + StudentID pgtype.Int8 `json:"student_id"` + AnswerText pgtype.Text `json:"answer_text"` + SolveMode pgtype.Text `json:"solve_mode"` + WorkingSteps pgtype.Text `json:"working_steps"` + IsCorrect pgtype.Bool `json:"is_correct"` + AiFeedback pgtype.Text `json:"ai_feedback"` + TeacherFeedback pgtype.Text `json:"teacher_feedback"` + AnswerStatus NullAnswerStatus `json:"answer_status"` + ReviewNeedsAttention pgtype.Bool `json:"review_needs_attention"` + ReviewIssueReason pgtype.Text `json:"review_issue_reason"` + ReviewCorrectnessScore pgtype.Numeric `json:"review_correctness_score"` + ReviewUnderstandingScore pgtype.Numeric `json:"review_understanding_score"` + ReviewQuestionScore pgtype.Numeric `json:"review_question_score"` + ReviewConfidence pgtype.Numeric `json:"review_confidence"` + ReviewTags []string `json:"review_tags"` + SubmittedAt pgtype.Timestamptz `json:"submitted_at"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + AnswerCreatedAt pgtype.Timestamptz `json:"answer_created_at"` + AnswerUpdatedAt pgtype.Timestamptz `json:"answer_updated_at"` +} + +func (q *Queries) ListQuestionDetailsForAssignmentStudent(ctx context.Context, arg ListQuestionDetailsForAssignmentStudentParams) ([]ListQuestionDetailsForAssignmentStudentRow, error) { + rows, err := q.db.Query(ctx, listQuestionDetailsForAssignmentStudent, arg.AssignmentID, arg.StudentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListQuestionDetailsForAssignmentStudentRow{} + for rows.Next() { + var i ListQuestionDetailsForAssignmentStudentRow + if err := rows.Scan( + &i.AssignmentID, + &i.QuestionID, + &i.Position, + &i.Title, + &i.Prompt, + &i.Subject, + &i.Source, + &i.QuestionTags, + &i.QuestionStatus, + &i.CorrectAnswer, + &i.AssignmentAiFeedback, + &i.AssignmentTeacherFeedback, + &i.OverallScore, + &i.PassThreshold, + &i.NextStepOutcome, + &i.PassStatusOverride, + &i.PassStatus, + &i.AnswerID, + &i.StudentID, + &i.AnswerText, + &i.SolveMode, + &i.WorkingSteps, + &i.IsCorrect, + &i.AiFeedback, + &i.TeacherFeedback, + &i.AnswerStatus, + &i.ReviewNeedsAttention, + &i.ReviewIssueReason, + &i.ReviewCorrectnessScore, + &i.ReviewUnderstandingScore, + &i.ReviewQuestionScore, + &i.ReviewConfidence, + &i.ReviewTags, + &i.SubmittedAt, + &i.ReviewedAt, + &i.AnswerCreatedAt, + &i.AnswerUpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listStudentPlanningPerformance = `-- name: ListStudentPlanningPerformance :many +SELECT + sa.assignment_id, + sa.question_id, + q.topic, + q.subject, + q.difficulty, + COALESCE( + ARRAY( + SELECT t.name + FROM question_tags qt + JOIN tags t ON t.id = qt.tag_id + WHERE qt.question_id = sa.question_id + ORDER BY t.name ASC + ), + ARRAY[]::TEXT[] + )::TEXT[] AS question_tags, + sa.is_correct, + sa.review_understanding_score, + sa.review_needs_attention, + sa.review_issue_reason, + sa.status, + sa.submitted_at, + sa.reviewed_at, + sa.updated_at +FROM student_answers sa +JOIN questions q ON q.id = sa.question_id +WHERE sa.student_id = $1 + AND sa.status IN ('submitted'::answer_status, 'reviewed'::answer_status) +ORDER BY COALESCE(sa.reviewed_at, sa.submitted_at, sa.updated_at) DESC, sa.id DESC +` + +type ListStudentPlanningPerformanceRow struct { + AssignmentID int64 `json:"assignment_id"` + QuestionID int64 `json:"question_id"` + Topic NullQuestionTopic `json:"topic"` + Subject pgtype.Text `json:"subject"` + Difficulty NullQuestionDifficulty `json:"difficulty"` + QuestionTags []string `json:"question_tags"` + IsCorrect pgtype.Bool `json:"is_correct"` + ReviewUnderstandingScore pgtype.Numeric `json:"review_understanding_score"` + ReviewNeedsAttention bool `json:"review_needs_attention"` + ReviewIssueReason pgtype.Text `json:"review_issue_reason"` + Status AnswerStatus `json:"status"` + SubmittedAt pgtype.Timestamptz `json:"submitted_at"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) ListStudentPlanningPerformance(ctx context.Context, studentID int64) ([]ListStudentPlanningPerformanceRow, error) { + rows, err := q.db.Query(ctx, listStudentPlanningPerformance, studentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListStudentPlanningPerformanceRow{} + for rows.Next() { + var i ListStudentPlanningPerformanceRow + if err := rows.Scan( + &i.AssignmentID, + &i.QuestionID, + &i.Topic, + &i.Subject, + &i.Difficulty, + &i.QuestionTags, + &i.IsCorrect, + &i.ReviewUnderstandingScore, + &i.ReviewNeedsAttention, + &i.ReviewIssueReason, + &i.Status, + &i.SubmittedAt, + &i.ReviewedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateAnswerAIReview = `-- name: UpdateAnswerAIReview :one +UPDATE student_answers +SET + ai_feedback = $2, + review_needs_attention = $3, + review_issue_reason = $4, + review_correctness_score = $5, + review_understanding_score = $6, + review_question_score = $7, + review_confidence = $8, + updated_at = NOW() +WHERE id = $1 +RETURNING id, assignment_id, question_id, student_id, answer_text, ai_feedback, teacher_feedback, status, submitted_at, reviewed_at, created_at, updated_at, solve_mode, working_steps, is_correct, review_needs_attention, review_issue_reason, review_correctness_score, review_understanding_score, review_question_score, review_confidence, review_tags +` + +type UpdateAnswerAIReviewParams struct { + ID int64 `json:"id"` + AiFeedback pgtype.Text `json:"ai_feedback"` + ReviewNeedsAttention bool `json:"review_needs_attention"` + ReviewIssueReason pgtype.Text `json:"review_issue_reason"` + ReviewCorrectnessScore pgtype.Numeric `json:"review_correctness_score"` + ReviewUnderstandingScore pgtype.Numeric `json:"review_understanding_score"` + ReviewQuestionScore pgtype.Numeric `json:"review_question_score"` + ReviewConfidence pgtype.Numeric `json:"review_confidence"` +} + +func (q *Queries) UpdateAnswerAIReview(ctx context.Context, arg UpdateAnswerAIReviewParams) (StudentAnswer, error) { + row := q.db.QueryRow(ctx, updateAnswerAIReview, + arg.ID, + arg.AiFeedback, + arg.ReviewNeedsAttention, + arg.ReviewIssueReason, + arg.ReviewCorrectnessScore, + arg.ReviewUnderstandingScore, + arg.ReviewQuestionScore, + arg.ReviewConfidence, + ) + var i StudentAnswer + err := row.Scan( + &i.ID, + &i.AssignmentID, + &i.QuestionID, + &i.StudentID, + &i.AnswerText, + &i.AiFeedback, + &i.TeacherFeedback, + &i.Status, + &i.SubmittedAt, + &i.ReviewedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.SolveMode, + &i.WorkingSteps, + &i.IsCorrect, + &i.ReviewNeedsAttention, + &i.ReviewIssueReason, + &i.ReviewCorrectnessScore, + &i.ReviewUnderstandingScore, + &i.ReviewQuestionScore, + &i.ReviewConfidence, + &i.ReviewTags, + ) + return i, err +} + +const updateAnswerReview = `-- name: UpdateAnswerReview :one +UPDATE student_answers +SET + status = $2, + review_needs_attention = $3, + review_issue_reason = $4, + review_correctness_score = $5, + review_understanding_score = $6, + review_question_score = $7, + review_confidence = $8, + review_tags = $9, + reviewed_at = CASE + WHEN $2::answer_status = 'reviewed' THEN NOW() + ELSE NULL + END, + updated_at = NOW() +WHERE id = $1 +RETURNING id, assignment_id, question_id, student_id, answer_text, ai_feedback, teacher_feedback, status, submitted_at, reviewed_at, created_at, updated_at, solve_mode, working_steps, is_correct, review_needs_attention, review_issue_reason, review_correctness_score, review_understanding_score, review_question_score, review_confidence, review_tags +` + +type UpdateAnswerReviewParams struct { + ID int64 `json:"id"` + Status AnswerStatus `json:"status"` + ReviewNeedsAttention bool `json:"review_needs_attention"` + ReviewIssueReason pgtype.Text `json:"review_issue_reason"` + ReviewCorrectnessScore pgtype.Numeric `json:"review_correctness_score"` + ReviewUnderstandingScore pgtype.Numeric `json:"review_understanding_score"` + ReviewQuestionScore pgtype.Numeric `json:"review_question_score"` + ReviewConfidence pgtype.Numeric `json:"review_confidence"` + ReviewTags []string `json:"review_tags"` +} + +func (q *Queries) UpdateAnswerReview(ctx context.Context, arg UpdateAnswerReviewParams) (StudentAnswer, error) { + row := q.db.QueryRow(ctx, updateAnswerReview, + arg.ID, + arg.Status, + arg.ReviewNeedsAttention, + arg.ReviewIssueReason, + arg.ReviewCorrectnessScore, + arg.ReviewUnderstandingScore, + arg.ReviewQuestionScore, + arg.ReviewConfidence, + arg.ReviewTags, + ) + var i StudentAnswer + err := row.Scan( + &i.ID, + &i.AssignmentID, + &i.QuestionID, + &i.StudentID, + &i.AnswerText, + &i.AiFeedback, + &i.TeacherFeedback, + &i.Status, + &i.SubmittedAt, + &i.ReviewedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.SolveMode, + &i.WorkingSteps, + &i.IsCorrect, + &i.ReviewNeedsAttention, + &i.ReviewIssueReason, + &i.ReviewCorrectnessScore, + &i.ReviewUnderstandingScore, + &i.ReviewQuestionScore, + &i.ReviewConfidence, + &i.ReviewTags, + ) + return i, err +} + +const upsertStudentAnswer = `-- name: UpsertStudentAnswer :one +INSERT INTO student_answers ( + assignment_id, + question_id, + student_id, + answer_text, + solve_mode, + working_steps, + ai_feedback, + teacher_feedback, + status, + submitted_at, + reviewed_at, + is_correct +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + $11, + $12 +) +ON CONFLICT (assignment_id, question_id, student_id) DO UPDATE +SET + answer_text = EXCLUDED.answer_text, + solve_mode = EXCLUDED.solve_mode, + working_steps = EXCLUDED.working_steps, + ai_feedback = EXCLUDED.ai_feedback, + teacher_feedback = EXCLUDED.teacher_feedback, + status = EXCLUDED.status, + submitted_at = EXCLUDED.submitted_at, + reviewed_at = EXCLUDED.reviewed_at, + is_correct = EXCLUDED.is_correct, + updated_at = NOW() +RETURNING id, assignment_id, question_id, student_id, answer_text, ai_feedback, teacher_feedback, status, submitted_at, reviewed_at, created_at, updated_at, solve_mode, working_steps, is_correct, review_needs_attention, review_issue_reason, review_correctness_score, review_understanding_score, review_question_score, review_confidence, review_tags +` + +type UpsertStudentAnswerParams struct { + AssignmentID int64 `json:"assignment_id"` + QuestionID int64 `json:"question_id"` + StudentID int64 `json:"student_id"` + AnswerText pgtype.Text `json:"answer_text"` + SolveMode string `json:"solve_mode"` + WorkingSteps pgtype.Text `json:"working_steps"` + AiFeedback pgtype.Text `json:"ai_feedback"` + TeacherFeedback pgtype.Text `json:"teacher_feedback"` + Status AnswerStatus `json:"status"` + SubmittedAt pgtype.Timestamptz `json:"submitted_at"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + IsCorrect pgtype.Bool `json:"is_correct"` +} + +func (q *Queries) UpsertStudentAnswer(ctx context.Context, arg UpsertStudentAnswerParams) (StudentAnswer, error) { + row := q.db.QueryRow(ctx, upsertStudentAnswer, + arg.AssignmentID, + arg.QuestionID, + arg.StudentID, + arg.AnswerText, + arg.SolveMode, + arg.WorkingSteps, + arg.AiFeedback, + arg.TeacherFeedback, + arg.Status, + arg.SubmittedAt, + arg.ReviewedAt, + arg.IsCorrect, + ) + var i StudentAnswer + err := row.Scan( + &i.ID, + &i.AssignmentID, + &i.QuestionID, + &i.StudentID, + &i.AnswerText, + &i.AiFeedback, + &i.TeacherFeedback, + &i.Status, + &i.SubmittedAt, + &i.ReviewedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.SolveMode, + &i.WorkingSteps, + &i.IsCorrect, + &i.ReviewNeedsAttention, + &i.ReviewIssueReason, + &i.ReviewCorrectnessScore, + &i.ReviewUnderstandingScore, + &i.ReviewQuestionScore, + &i.ReviewConfidence, + &i.ReviewTags, + ) + return i, err +} diff --git a/Backend/internal/sqlc/users.sql.go b/Backend/internal/sqlc/users.sql.go new file mode 100644 index 0000000..4747c2b --- /dev/null +++ b/Backend/internal/sqlc/users.sql.go @@ -0,0 +1,577 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: users.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createUser = `-- name: CreateUser :one +INSERT INTO users ( + email, + password_hash, + role, + full_name +) VALUES ( + $1, + $2, + $3, + $4 +) +RETURNING id, email, password_hash, role, full_name, is_active, created_at, updated_at +` + +type CreateUserParams struct { + Email string `json:"email"` + PasswordHash pgtype.Text `json:"password_hash"` + Role UserRole `json:"role"` + FullName string `json:"full_name"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRow(ctx, createUser, + arg.Email, + arg.PasswordHash, + arg.Role, + arg.FullName, + ) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Role, + &i.FullName, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getAuthUserByEmail = `-- name: GetAuthUserByEmail :one +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + u.is_active AS user_is_active, + u.password_hash AS user_password_hash, + u.created_at AS user_created_at, + u.updated_at AS user_updated_at, + p.user_id AS profile_user_id, + p.preferred_name, + p.profile_icon_url, + p.headline, + p.bio, + p.timezone, + p.locale, + p.grade_level, + p.learning_goal, + p.created_at AS profile_created_at, + p.updated_at AS profile_updated_at +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.email = $1 +` + +type GetAuthUserByEmailRow struct { + UserID int64 `json:"user_id"` + UserEmail string `json:"user_email"` + UserRole UserRole `json:"user_role"` + UserFullName string `json:"user_full_name"` + UserIsActive bool `json:"user_is_active"` + UserPasswordHash pgtype.Text `json:"user_password_hash"` + UserCreatedAt pgtype.Timestamptz `json:"user_created_at"` + UserUpdatedAt pgtype.Timestamptz `json:"user_updated_at"` + ProfileUserID pgtype.Int8 `json:"profile_user_id"` + PreferredName pgtype.Text `json:"preferred_name"` + ProfileIconUrl pgtype.Text `json:"profile_icon_url"` + Headline pgtype.Text `json:"headline"` + Bio pgtype.Text `json:"bio"` + Timezone pgtype.Text `json:"timezone"` + Locale pgtype.Text `json:"locale"` + GradeLevel pgtype.Text `json:"grade_level"` + LearningGoal pgtype.Text `json:"learning_goal"` + ProfileCreatedAt pgtype.Timestamptz `json:"profile_created_at"` + ProfileUpdatedAt pgtype.Timestamptz `json:"profile_updated_at"` +} + +func (q *Queries) GetAuthUserByEmail(ctx context.Context, email string) (GetAuthUserByEmailRow, error) { + row := q.db.QueryRow(ctx, getAuthUserByEmail, email) + var i GetAuthUserByEmailRow + err := row.Scan( + &i.UserID, + &i.UserEmail, + &i.UserRole, + &i.UserFullName, + &i.UserIsActive, + &i.UserPasswordHash, + &i.UserCreatedAt, + &i.UserUpdatedAt, + &i.ProfileUserID, + &i.PreferredName, + &i.ProfileIconUrl, + &i.Headline, + &i.Bio, + &i.Timezone, + &i.Locale, + &i.GradeLevel, + &i.LearningGoal, + &i.ProfileCreatedAt, + &i.ProfileUpdatedAt, + ) + return i, err +} + +const getAuthUserByID = `-- name: GetAuthUserByID :one +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + u.is_active AS user_is_active, + u.created_at AS user_created_at, + u.updated_at AS user_updated_at, + p.user_id AS profile_user_id, + p.preferred_name, + p.profile_icon_url, + p.headline, + p.bio, + p.timezone, + p.locale, + p.grade_level, + p.learning_goal, + p.created_at AS profile_created_at, + p.updated_at AS profile_updated_at +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.id = $1 +` + +type GetAuthUserByIDRow struct { + UserID int64 `json:"user_id"` + UserEmail string `json:"user_email"` + UserRole UserRole `json:"user_role"` + UserFullName string `json:"user_full_name"` + UserIsActive bool `json:"user_is_active"` + UserCreatedAt pgtype.Timestamptz `json:"user_created_at"` + UserUpdatedAt pgtype.Timestamptz `json:"user_updated_at"` + ProfileUserID pgtype.Int8 `json:"profile_user_id"` + PreferredName pgtype.Text `json:"preferred_name"` + ProfileIconUrl pgtype.Text `json:"profile_icon_url"` + Headline pgtype.Text `json:"headline"` + Bio pgtype.Text `json:"bio"` + Timezone pgtype.Text `json:"timezone"` + Locale pgtype.Text `json:"locale"` + GradeLevel pgtype.Text `json:"grade_level"` + LearningGoal pgtype.Text `json:"learning_goal"` + ProfileCreatedAt pgtype.Timestamptz `json:"profile_created_at"` + ProfileUpdatedAt pgtype.Timestamptz `json:"profile_updated_at"` +} + +func (q *Queries) GetAuthUserByID(ctx context.Context, id int64) (GetAuthUserByIDRow, error) { + row := q.db.QueryRow(ctx, getAuthUserByID, id) + var i GetAuthUserByIDRow + err := row.Scan( + &i.UserID, + &i.UserEmail, + &i.UserRole, + &i.UserFullName, + &i.UserIsActive, + &i.UserCreatedAt, + &i.UserUpdatedAt, + &i.ProfileUserID, + &i.PreferredName, + &i.ProfileIconUrl, + &i.Headline, + &i.Bio, + &i.Timezone, + &i.Locale, + &i.GradeLevel, + &i.LearningGoal, + &i.ProfileCreatedAt, + &i.ProfileUpdatedAt, + ) + return i, err +} + +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT id, email, password_hash, role, full_name, is_active, created_at, updated_at +FROM users +WHERE email = $1 +` + +func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { + row := q.db.QueryRow(ctx, getUserByEmail, email) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Role, + &i.FullName, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, email, password_hash, role, full_name, is_active, created_at, updated_at +FROM users +WHERE id = $1 +` + +func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { + row := q.db.QueryRow(ctx, getUserByID, id) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Role, + &i.FullName, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getUserWithProfileByID = `-- name: GetUserWithProfileByID :one +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + u.is_active AS user_is_active, + u.created_at AS user_created_at, + u.updated_at AS user_updated_at, + p.user_id AS profile_user_id, + p.preferred_name, + p.profile_icon_url, + p.headline, + p.bio, + p.timezone, + p.locale, + p.grade_level, + p.learning_goal, + p.created_at AS profile_created_at, + p.updated_at AS profile_updated_at +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.id = $1 +` + +type GetUserWithProfileByIDRow struct { + UserID int64 `json:"user_id"` + UserEmail string `json:"user_email"` + UserRole UserRole `json:"user_role"` + UserFullName string `json:"user_full_name"` + UserIsActive bool `json:"user_is_active"` + UserCreatedAt pgtype.Timestamptz `json:"user_created_at"` + UserUpdatedAt pgtype.Timestamptz `json:"user_updated_at"` + ProfileUserID pgtype.Int8 `json:"profile_user_id"` + PreferredName pgtype.Text `json:"preferred_name"` + ProfileIconUrl pgtype.Text `json:"profile_icon_url"` + Headline pgtype.Text `json:"headline"` + Bio pgtype.Text `json:"bio"` + Timezone pgtype.Text `json:"timezone"` + Locale pgtype.Text `json:"locale"` + GradeLevel pgtype.Text `json:"grade_level"` + LearningGoal pgtype.Text `json:"learning_goal"` + ProfileCreatedAt pgtype.Timestamptz `json:"profile_created_at"` + ProfileUpdatedAt pgtype.Timestamptz `json:"profile_updated_at"` +} + +func (q *Queries) GetUserWithProfileByID(ctx context.Context, id int64) (GetUserWithProfileByIDRow, error) { + row := q.db.QueryRow(ctx, getUserWithProfileByID, id) + var i GetUserWithProfileByIDRow + err := row.Scan( + &i.UserID, + &i.UserEmail, + &i.UserRole, + &i.UserFullName, + &i.UserIsActive, + &i.UserCreatedAt, + &i.UserUpdatedAt, + &i.ProfileUserID, + &i.PreferredName, + &i.ProfileIconUrl, + &i.Headline, + &i.Bio, + &i.Timezone, + &i.Locale, + &i.GradeLevel, + &i.LearningGoal, + &i.ProfileCreatedAt, + &i.ProfileUpdatedAt, + ) + return i, err +} + +const listUsersByRole = `-- name: ListUsersByRole :many +SELECT id, email, password_hash, role, full_name, is_active, created_at, updated_at +FROM users +WHERE role = $1 +ORDER BY full_name ASC +` + +func (q *Queries) ListUsersByRole(ctx context.Context, role UserRole) ([]User, error) { + rows, err := q.db.Query(ctx, listUsersByRole, role) + if err != nil { + return nil, err + } + defer rows.Close() + items := []User{} + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Role, + &i.FullName, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUsersWithProfileByRole = `-- name: ListUsersWithProfileByRole :many +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + u.is_active AS user_is_active, + u.created_at AS user_created_at, + u.updated_at AS user_updated_at, + p.user_id AS profile_user_id, + p.preferred_name, + p.profile_icon_url, + p.headline, + p.bio, + p.timezone, + p.locale, + p.grade_level, + p.learning_goal, + p.created_at AS profile_created_at, + p.updated_at AS profile_updated_at +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.role = $1 +ORDER BY u.full_name ASC +` + +type ListUsersWithProfileByRoleRow struct { + UserID int64 `json:"user_id"` + UserEmail string `json:"user_email"` + UserRole UserRole `json:"user_role"` + UserFullName string `json:"user_full_name"` + UserIsActive bool `json:"user_is_active"` + UserCreatedAt pgtype.Timestamptz `json:"user_created_at"` + UserUpdatedAt pgtype.Timestamptz `json:"user_updated_at"` + ProfileUserID pgtype.Int8 `json:"profile_user_id"` + PreferredName pgtype.Text `json:"preferred_name"` + ProfileIconUrl pgtype.Text `json:"profile_icon_url"` + Headline pgtype.Text `json:"headline"` + Bio pgtype.Text `json:"bio"` + Timezone pgtype.Text `json:"timezone"` + Locale pgtype.Text `json:"locale"` + GradeLevel pgtype.Text `json:"grade_level"` + LearningGoal pgtype.Text `json:"learning_goal"` + ProfileCreatedAt pgtype.Timestamptz `json:"profile_created_at"` + ProfileUpdatedAt pgtype.Timestamptz `json:"profile_updated_at"` +} + +func (q *Queries) ListUsersWithProfileByRole(ctx context.Context, role UserRole) ([]ListUsersWithProfileByRoleRow, error) { + rows, err := q.db.Query(ctx, listUsersWithProfileByRole, role) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListUsersWithProfileByRoleRow{} + for rows.Next() { + var i ListUsersWithProfileByRoleRow + if err := rows.Scan( + &i.UserID, + &i.UserEmail, + &i.UserRole, + &i.UserFullName, + &i.UserIsActive, + &i.UserCreatedAt, + &i.UserUpdatedAt, + &i.ProfileUserID, + &i.PreferredName, + &i.ProfileIconUrl, + &i.Headline, + &i.Bio, + &i.Timezone, + &i.Locale, + &i.GradeLevel, + &i.LearningGoal, + &i.ProfileCreatedAt, + &i.ProfileUpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateUserActiveStatus = `-- name: UpdateUserActiveStatus :one +UPDATE users +SET + is_active = $2, + updated_at = NOW() +WHERE id = $1 +RETURNING id, email, password_hash, role, full_name, is_active, created_at, updated_at +` + +type UpdateUserActiveStatusParams struct { + ID int64 `json:"id"` + IsActive bool `json:"is_active"` +} + +func (q *Queries) UpdateUserActiveStatus(ctx context.Context, arg UpdateUserActiveStatusParams) (User, error) { + row := q.db.QueryRow(ctx, updateUserActiveStatus, arg.ID, arg.IsActive) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Role, + &i.FullName, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateUserFullName = `-- name: UpdateUserFullName :one +UPDATE users +SET + full_name = $2, + updated_at = NOW() +WHERE id = $1 +RETURNING id, email, password_hash, role, full_name, is_active, created_at, updated_at +` + +type UpdateUserFullNameParams struct { + ID int64 `json:"id"` + FullName string `json:"full_name"` +} + +func (q *Queries) UpdateUserFullName(ctx context.Context, arg UpdateUserFullNameParams) (User, error) { + row := q.db.QueryRow(ctx, updateUserFullName, arg.ID, arg.FullName) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Role, + &i.FullName, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertUserProfile = `-- name: UpsertUserProfile :one +INSERT INTO profiles ( + user_id, + preferred_name, + profile_icon_url, + headline, + bio, + timezone, + locale, + grade_level, + learning_goal +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9 +) +ON CONFLICT (user_id) DO UPDATE +SET + preferred_name = EXCLUDED.preferred_name, + profile_icon_url = EXCLUDED.profile_icon_url, + headline = EXCLUDED.headline, + bio = EXCLUDED.bio, + timezone = EXCLUDED.timezone, + locale = EXCLUDED.locale, + grade_level = EXCLUDED.grade_level, + learning_goal = EXCLUDED.learning_goal, + updated_at = NOW() +RETURNING user_id, preferred_name, profile_icon_url, headline, bio, timezone, locale, grade_level, learning_goal, created_at, updated_at +` + +type UpsertUserProfileParams struct { + UserID int64 `json:"user_id"` + PreferredName pgtype.Text `json:"preferred_name"` + ProfileIconUrl pgtype.Text `json:"profile_icon_url"` + Headline pgtype.Text `json:"headline"` + Bio pgtype.Text `json:"bio"` + Timezone pgtype.Text `json:"timezone"` + Locale pgtype.Text `json:"locale"` + GradeLevel pgtype.Text `json:"grade_level"` + LearningGoal pgtype.Text `json:"learning_goal"` +} + +func (q *Queries) UpsertUserProfile(ctx context.Context, arg UpsertUserProfileParams) (Profile, error) { + row := q.db.QueryRow(ctx, upsertUserProfile, + arg.UserID, + arg.PreferredName, + arg.ProfileIconUrl, + arg.Headline, + arg.Bio, + arg.Timezone, + arg.Locale, + arg.GradeLevel, + arg.LearningGoal, + ) + var i Profile + err := row.Scan( + &i.UserID, + &i.PreferredName, + &i.ProfileIconUrl, + &i.Headline, + &i.Bio, + &i.Timezone, + &i.Locale, + &i.GradeLevel, + &i.LearningGoal, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/Caddyfile b/Caddyfile index 4c87f33..7ca0819 100644 --- a/Caddyfile +++ b/Caddyfile @@ -7,7 +7,11 @@ respond "ok" 200 } + handle_path /api/* { + reverse_proxy {$BACKEND_UPSTREAM} + } + handle { reverse_proxy {$FRONTEND_UPSTREAM} } -} \ No newline at end of file +} diff --git a/Caddyfile.prod-a b/Caddyfile.prod-a new file mode 100644 index 0000000..3cb9e68 --- /dev/null +++ b/Caddyfile.prod-a @@ -0,0 +1,13 @@ +{$BASE_DOMAIN} { + handle /health { + respond "ok" 200 + } + + handle_path /api/* { + reverse_proxy {$BACKEND_UPSTREAM} + } + + handle { + reverse_proxy {$FRONTEND_UPSTREAM} + } +} diff --git a/Earthfile b/Earthfile new file mode 100644 index 0000000..e9e969c --- /dev/null +++ b/Earthfile @@ -0,0 +1,39 @@ +VERSION 0.8 + +frontend-node-base: + FROM node:24.12.0-alpine + WORKDIR /workspace/BoostAI/Frontend + RUN corepack enable && corepack prepare pnpm@10.24.0 --activate + COPY Frontend/package.json Frontend/pnpm-lock.yaml ./ + +frontend-deps: + FROM +frontend-node-base + RUN pnpm install --frozen-lockfile + +frontend-prod-image: + ARG IMAGE_NAME="boost-ai/demo-frontend-prod-a" + ARG TAG="latest" + + FROM +frontend-deps + COPY Frontend/. ./ + COPY Mock-Data ../Mock-Data + RUN pnpm build + + ENV NODE_ENV=production + ENV HOST=0.0.0.0 + ENV PORT=3000 + ENV NITRO_HOST=0.0.0.0 + ENV NITRO_PORT=3000 + EXPOSE 3000 + + ENTRYPOINT ["node", ".output/server/index.mjs"] + + SAVE IMAGE $IMAGE_NAME:$TAG + +frontend-prod-image-push: + ARG REGISTRY="registry.mangopig.tech" + ARG IMAGE_NAME="boost-ai/demo-frontend-prod-a" + ARG TAG="latest" + + FROM +frontend-prod-image --IMAGE_NAME=$IMAGE_NAME --TAG=$TAG + SAVE IMAGE --push $REGISTRY/$IMAGE_NAME:$TAG diff --git a/Frontend/Earthfile b/Frontend/Earthfile index 4bb089a..30f913a 100644 --- a/Frontend/Earthfile +++ b/Frontend/Earthfile @@ -17,7 +17,6 @@ build: SAVE ARTIFACT dist AS LOCAL ./dist dev-image: - ARG REGISTRY="registry.mangopig.tech" ARG IMAGE_NAME="boost-ai/demo-frontend-dev" ARG TAG="latest" @@ -29,6 +28,38 @@ dev-image: EXPOSE 4321 SAVE IMAGE $IMAGE_NAME:$TAG + +dev-image-push: + ARG REGISTRY="registry.mangopig.tech" + ARG IMAGE_NAME="boost-ai/demo-frontend-dev" + ARG TAG="latest" + + FROM +dev-image --IMAGE_NAME=$IMAGE_NAME --TAG=$TAG + SAVE IMAGE --push $REGISTRY/$IMAGE_NAME:$TAG + +prod-image: + ARG IMAGE_NAME="boost-ai/demo-frontend-prod-a" + ARG TAG="latest" + + FROM +deps + COPY . . + RUN pnpm build + + ENV NODE_ENV=production + ENV HOST=0.0.0.0 + ENV PORT=3000 + EXPOSE 3000 + + ENTRYPOINT ["pnpm", "start", "--host", "0.0.0.0", "--port", "3000"] + + SAVE IMAGE $IMAGE_NAME:$TAG + +prod-image-push: + ARG REGISTRY="registry.mangopig.tech" + ARG IMAGE_NAME="boost-ai/demo-frontend-prod-a" + ARG TAG="latest" + + FROM +prod-image --IMAGE_NAME=$IMAGE_NAME --TAG=$TAG SAVE IMAGE --push $REGISTRY/$IMAGE_NAME:$TAG # image: diff --git a/Frontend/public/brand/boost-ai-logo-purple.png b/Frontend/public/brand/boost-ai-logo-purple.png new file mode 100644 index 0000000000000000000000000000000000000000..b07609d6aa000cea4e7cfa8c2b3e7b8f8d6d4f0b GIT binary patch literal 45344 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 PU19yX{CNa!cLe?io7cg% literal 664 zcmV;J0%!e+P)m9ebk1R zejT~~6f_`?;`cEd!+`7(hw@%%2;?RN8gX-L?z6cM( zKoG@&w+0}f@Pfvwc+deid)qgE!L$ENKYjViZC_Zcr>L(`2oXUT8f0mRQ(6-=HN_Ai zeBBEz3WP+1Cw`m!49Wf!MnZzp5bH8VkR~BcJ1s-j90TAS2Yo4j!J|KodxYR%3Numw zA?gq6e`5@!W~F$_De3yt&uspo&2yLb$(NwcPPI-4LGc!}HdY%jfq@AFs8LiZ4k(p} zZ!c9o+qbWYs-Mg zgdyTALzJX&7QXHdI_DPTFL33;w}88{e6Zk)MX0kN{3DX9uz#O_L58&XRH$Nvvu;fO zf&)7@?C~$z1K<>j0ga$$MIg+5xN;eQ?1-CA=`^Y169@Ab6!vcaNP=hxfKN%@Ly^R* zK1iv*s1Yl6_dVyz8>ZqYhz6J4|3fQ@2LQeX@^%W(B~8>=MoEmBEGGD1;gHXlpX>!W ym)!leA2L@`cpb^hy)P75=I!`pBYxP7<2VfQ3j76qLgzIA0000 { const location = useLocation(); - const isViewportLockedRoute = () => location.pathname === "/" || location.pathname.startsWith("/auth/"); + const navigate = useNavigate(); + const auth = useAuth(); + const routeIsPublic = () => isPublicRoute(location.pathname); + + createEffect(() => { + if (!auth.isReady()) return; + + const pathname = location.pathname; + if (pathname === "/") return; + + if (pathname.startsWith("/auth/")) { + if (auth.user()) { + navigate(getPostAuthRedirectHref(auth.user()!.role), { replace: true }); + } + return; + } + + if (!auth.user()) { + navigate("/auth/login", { replace: true }); + } + }); return ( -
- - - {(path) => ( -
- {props.children} -
- )} -
-
-
+ {!auth.isReady() && !routeIsPublic() ? ( +
+
+ Loading your session… +
+
+ ) : ( +
+ {props.children} +
+ )}
); }; @@ -34,9 +55,11 @@ const AppRoot: ParentComponent = (props) => { export default function App() { return ( - - - + + + + + ); } diff --git a/Frontend/src/components/assignment/assignment-tabs.tsx b/Frontend/src/components/assignment/assignment-tabs.tsx deleted file mode 100644 index 03db768..0000000 --- a/Frontend/src/components/assignment/assignment-tabs.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { A, useLocation, useParams } from "@solidjs/router"; -import type { Component } from "solid-js"; -import styles from "../../routes/assignment/assignment-page.module.scss"; - -const AssignmentTabs: Component = () => { - const params = useParams(); - const location = useLocation(); - - const reviewHref = () => `/assignment/${params.id}`; - const workHref = () => `/assignment/${params.id}/work`; - const isWork = () => location.pathname === workHref(); - - return ( - - ); -}; - -export default AssignmentTabs; diff --git a/Frontend/src/components/assignment/assignment.data.ts b/Frontend/src/components/assignment/assignment.data.ts deleted file mode 100644 index d371972..0000000 --- a/Frontend/src/components/assignment/assignment.data.ts +++ /dev/null @@ -1,251 +0,0 @@ -import rawAssignments from "../../../../Mock-Data/assignments.json"; -import rawAssignmentAssignees from "../../../../Mock-Data/assignment_assignees.json"; -import rawAssignmentQuestions from "../../../../Mock-Data/assignment_questions.json"; -import rawQuestionBank from "../../../../Mock-Data/question_bank.json"; -import rawStudentAnswers from "../../../../Mock-Data/student_answers.json"; -import rawStudents from "../../../../Mock-Data/students.json"; -import rawClassroom from "../../../../Mock-Data/classroom.json"; - -type Assignment = { - id: number; - name: string; - topic: string; - due_date: number; - status: "DRAFT" | "PUBLISHED" | "CLOSED"; - maximum_marks: number; -}; - -type AssignmentAssignee = { - id: number; - assignment_id: number; - student_id: number; - status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED"; - total_marks: number; - started_at: number | null; - submitted_at: number | null; -}; - -type AssignmentQuestion = { - id: number; - assignment_id: number; - question_bank_id: number; - question_order: number; - maximum_marks: number; -}; - -type QuestionBankItem = { - id: number; - topic: string; - sub_topic: string | null; - difficulty: "EASY" | "MEDIUM" | "HARD"; - question_text: string; - correct_answer: string; - step_by_step_solution: string | null; -}; - -type StudentAnswer = { - assignee_id: number; - assignment_question_id: number; - extracted_answer: string; - ai_reasoning: string; - _solve_mode: "just_answer" | "step_by_step" | "solve_together" | "handwritten"; - _is_correct: boolean; - _time_on_task_seconds: number; -}; - -type Student = { - id: number; - fullname: string; - _persona: string; -}; - -type ClassroomFile = { - classroom: { - name: string; - target_level: number; - }; - tutor: { - fullname: string; - }; -}; - -export type AssignmentPageData = { - id: number; - title: string; - topic: string; - status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED"; - statusLabel: string; - dueLabel: string; - studentName: string; - classroomName: string; - tutorName: string; - headline: string; - description: string; - primaryAction: string; - primaryHref: string; - stats: Array<{ label: string; value: string }>; - coachCard: { - title: string; - description: string; - items: string[]; - }; - questions: Array<{ - id: number; - order: number; - prompt: string; - topic: string; - subTopic: string | null; - difficulty: "EASY" | "MEDIUM" | "HARD"; - marks: number; - statusLabel: string; - statusTone: "success" | "warning" | "muted"; - responseLabel: string; - responseValue: string; - feedback: string; - solveModeLabel?: string; - initialAnswer?: string; - initialSolveMode?: "just_answer" | "step_by_step" | "solve_together" | "handwritten"; - showAnswerKey: boolean; - correctAnswer: string; - }>; -}; - -const assignments = rawAssignments as Assignment[]; -const assignmentAssignees = rawAssignmentAssignees as AssignmentAssignee[]; -const assignmentQuestions = rawAssignmentQuestions as AssignmentQuestion[]; -const questionBank = rawQuestionBank as QuestionBankItem[]; -const studentAnswers = rawStudentAnswers as StudentAnswer[]; -const students = rawStudents as Student[]; -const classroomFile = rawClassroom as ClassroomFile; - -const defaultStudentId = 201; - -const assignmentById = new Map(assignments.map((entry) => [entry.id, entry])); -const questionById = new Map(questionBank.map((entry) => [entry.id, entry])); -const student = students.find((entry) => entry.id === defaultStudentId) ?? students[0]; - -const formatDate = (timestamp: number) => - new Intl.DateTimeFormat("en-GB", { - weekday: "short", - month: "short", - day: "numeric", - }).format(new Date(timestamp)); - -const formatSolveMode = (value: StudentAnswer["_solve_mode"]) => { - switch (value) { - case "just_answer": - return "Just answer"; - case "step_by_step": - return "Step by step"; - case "solve_together": - return "Solve together"; - case "handwritten": - return "Handwritten"; - } -}; - -const personaHint = (persona: string) => { - switch (persona) { - case "fraction_inversion": - return "Slow down on fraction rules and check each step before moving on."; - case "place_value_gaps": - return "Check place value carefully before you calculate the final answer."; - case "rushed_careless": - return "Pause before submitting so small slips do not cost easy marks."; - case "solve_together_dependent": - return "Try one independent attempt first, then ask for guided help if you need it."; - case "word_problem_weak": - return "Underline the key numbers and turn the sentence into a maths step first."; - default: - return "Work through one question at a time and keep your method tidy."; - } -}; - -export const getAssignmentPageData = (assignmentId: number): AssignmentPageData | null => { - const assignment = assignmentById.get(assignmentId); - if (!assignment) return null; - - const assignee = assignmentAssignees.find((entry) => entry.assignment_id === assignment.id && entry.student_id === student.id); - if (!assignee) return null; - - const assignmentQuestionRows = assignmentQuestions - .filter((entry) => entry.assignment_id === assignment.id) - .sort((left, right) => left.question_order - right.question_order) - .map((entry) => { - const question = questionById.get(entry.question_bank_id); - if (!question) throw new Error(`Missing question bank record ${entry.question_bank_id}`); - - const answer = studentAnswers.find((studentAnswer) => studentAnswer.assignee_id === assignee.id && studentAnswer.assignment_question_id === entry.id); - - return { entry, question, answer }; - }); - - const answeredCount = assignmentQuestionRows.filter((row) => !!row.answer).length; - const correctCount = assignmentQuestionRows.filter((row) => row.answer?._is_correct).length; - const accuracy = answeredCount > 0 ? Math.round((correctCount / answeredCount) * 100) : 0; - - const statusLabel = assignee.status === "SUBMITTED" ? "Submitted" : assignee.status === "IN_PROGRESS" ? "In progress" : "Not started"; - const primaryAction = assignee.status === "SUBMITTED" ? "Review assignment" : assignee.status === "IN_PROGRESS" ? "Continue assignment" : "Start assignment"; - - const questions = assignmentQuestionRows.map(({ entry, question, answer }) => ({ - id: entry.id, - order: entry.question_order, - prompt: question.question_text, - topic: question.topic, - subTopic: question.sub_topic, - difficulty: question.difficulty, - marks: entry.maximum_marks, - statusLabel: answer ? (answer._is_correct ? "Correct" : "Needs review") : "Not answered", - statusTone: answer ? (answer._is_correct ? "success" : "warning") : "muted", - responseLabel: answer ? "Your latest answer" : "Status", - responseValue: answer ? answer.extracted_answer : "No attempt yet", - feedback: answer ? answer.ai_reasoning : "This sample question is ready when you are.", - solveModeLabel: answer ? formatSolveMode(answer._solve_mode) : undefined, - initialAnswer: answer?.extracted_answer, - initialSolveMode: answer?._solve_mode, - showAnswerKey: assignee.status === "SUBMITTED", - correctAnswer: question.correct_answer, - })); - - return { - id: assignment.id, - title: assignment.name, - topic: assignment.topic, - status: assignee.status, - statusLabel, - dueLabel: formatDate(assignment.due_date), - studentName: student.fullname, - classroomName: classroomFile.classroom.name, - tutorName: classroomFile.tutor.fullname, - headline: - assignee.status === "SUBMITTED" - ? `Review how you did in ${assignment.topic}` - : assignee.status === "IN_PROGRESS" - ? `Keep going — you are already part way through` - : `Start this assignment with a steady first pass`, - description: - assignee.status === "SUBMITTED" - ? `You scored ${assignee.total_marks}/${assignment.maximum_marks}. Use the sample questions below to revisit what felt easy and what still needs another try.` - : assignee.status === "IN_PROGRESS" - ? `You have answered ${answeredCount} of ${assignmentQuestionRows.length} questions. Finish the rest while the topic is still fresh.` - : `This assignment has ${assignmentQuestionRows.length} sample questions. Start with the easier wins, then work up to the harder ones.`, - primaryAction, - primaryHref: `/assignment/${assignment.id}/work`, - stats: [ - { label: "Status", value: statusLabel }, - { label: "Due", value: formatDate(assignment.due_date) }, - { label: "Questions", value: `${assignmentQuestionRows.length}` }, - { label: assignee.status === "SUBMITTED" ? "Score" : "Answered", value: assignee.status === "SUBMITTED" ? `${assignee.total_marks}/${assignment.maximum_marks}` : `${answeredCount}/${assignmentQuestionRows.length}` }, - ], - coachCard: { - title: "How to approach this one", - description: personaHint(student._persona), - items: [ - `${assignment.topic} focus`, - `${accuracy}% accuracy so far`, - `${correctCount} correct answers logged`, - ], - }, - questions, - }; -}; diff --git a/Frontend/src/components/assignment/assignment-header.module.scss b/Frontend/src/components/assignment/shared/assignment-header.module.scss similarity index 65% rename from Frontend/src/components/assignment/assignment-header.module.scss rename to Frontend/src/components/assignment/shared/assignment-header.module.scss index 7cb3a76..c405c25 100644 --- a/Frontend/src/components/assignment/assignment-header.module.scss +++ b/Frontend/src/components/assignment/shared/assignment-header.module.scss @@ -1,14 +1,24 @@ +/* Path: Frontend/src/components/assignment/shared/assignment-header.module.scss */ + .headerCard { display: grid; gap: 1.1rem; padding: 1.5rem; - border-radius: 1.5rem; + border-radius: var(--radius-3xl); background: linear-gradient(135deg, var(--surface-hero-start), var(--surface-hero-end)); color: var(--text-on-accent); border: 1px solid var(--border-overlay); box-shadow: var(--shadow-elevated); } +@media (max-width: 768px) { + .headerCard { + gap: 0.8rem; + padding: 1rem; + border-radius: var(--radius-2xl); + } +} + .headerTop { display: flex; align-items: center; @@ -16,13 +26,21 @@ gap: 1rem; } +@media (max-width: 768px) { + .headerTop { + align-items: flex-start; + flex-wrap: wrap; + gap: 0.7rem; + } +} + .backLink, .statusPill { display: inline-flex; align-items: center; justify-content: center; padding: 0.55rem 0.85rem; - border-radius: 9999px; + border-radius: var(--radius-full); background: var(--surface-overlay-soft); border: 1px solid var(--border-overlay); color: var(--text-on-accent); @@ -47,6 +65,21 @@ } } +@media (max-width: 768px) { + .copy { + gap: 0.22rem; + } + + .copy h1 { + font-size: clamp(1.5rem, 1.15rem + 1.35vw, 2rem); + line-height: 1.04; + } + + .copy > p:not(.eyebrow) { + display: none; + } +} + .eyebrow { text-transform: uppercase; letter-spacing: 0.1em; diff --git a/Frontend/src/components/assignment/assignment-header.tsx b/Frontend/src/components/assignment/shared/assignment-header.tsx similarity index 70% rename from Frontend/src/components/assignment/assignment-header.tsx rename to Frontend/src/components/assignment/shared/assignment-header.tsx index ff39c41..6580352 100644 --- a/Frontend/src/components/assignment/assignment-header.tsx +++ b/Frontend/src/components/assignment/shared/assignment-header.tsx @@ -1,17 +1,21 @@ -import type { Component } from "solid-js"; +// Path: Frontend/src/components/assignment/shared/assignment-header.tsx + import { A } from "@solidjs/router"; -import type { AssignmentPageData } from "./assignment.data"; +import type { Component } from "solid-js"; +import { getDashboardHomeHref } from "../../../lib/routes"; import styles from "./assignment-header.module.scss"; +import type { AssignmentPageData } from "./assignment-types"; type Props = { data: AssignmentPageData; + backHref?: string; }; const AssignmentHeader: Component = (props) => { return (
- + Back to dashboard {props.data.statusLabel} diff --git a/Frontend/src/components/assignment/assignment-overview.module.scss b/Frontend/src/components/assignment/shared/assignment-overview.module.scss similarity index 66% rename from Frontend/src/components/assignment/assignment-overview.module.scss rename to Frontend/src/components/assignment/shared/assignment-overview.module.scss index b5f464d..3643ff1 100644 --- a/Frontend/src/components/assignment/assignment-overview.module.scss +++ b/Frontend/src/components/assignment/shared/assignment-overview.module.scss @@ -1,3 +1,5 @@ +/* Path: Frontend/src/components/assignment/shared/assignment-overview.module.scss */ + .stack { display: grid; gap: 1rem; @@ -7,7 +9,7 @@ display: grid; gap: 1rem; padding: 1.2rem; - border-radius: 1.35rem; + border-radius: var(--radius-2xl); background: var(--surface-panel); border: 1px solid var(--border-soft); box-shadow: var(--shadow-soft); @@ -39,7 +41,7 @@ display: grid; gap: 0.25rem; padding: 0.9rem; - border-radius: 1rem; + border-radius: var(--radius-md); background: var(--surface-panel-strong); border: 1px solid var(--border-divider); @@ -76,10 +78,35 @@ align-items: center; justify-content: center; padding: 0.9rem 1rem; - border-radius: 1rem; + border-radius: var(--radius-md); background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end)); color: var(--action-primary-text); font-weight: 600; box-shadow: var(--action-primary-shadow); text-decoration: none; } + +.feedbackBlock { + display: grid; + gap: 0.45rem; + padding: 0.95rem 1rem; + border-radius: var(--radius-md); + background: var(--surface-panel-strong); + border: 1px solid var(--border-divider); +} + +.feedbackLabel { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + font-weight: 700; +} + +.feedbackCopy { + font-size: 0.94rem; + line-height: 1.55; + color: var(--text-muted); + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/Frontend/src/components/assignment/assignment-overview.tsx b/Frontend/src/components/assignment/shared/assignment-overview.tsx similarity index 58% rename from Frontend/src/components/assignment/assignment-overview.tsx rename to Frontend/src/components/assignment/shared/assignment-overview.tsx index 7fd1c32..d4f873b 100644 --- a/Frontend/src/components/assignment/assignment-overview.tsx +++ b/Frontend/src/components/assignment/shared/assignment-overview.tsx @@ -1,8 +1,10 @@ +// Path: Frontend/src/components/assignment/shared/assignment-overview.tsx + +import { A } from "@solidjs/router"; import type { Component } from "solid-js"; import { For } from "solid-js"; -import { A } from "@solidjs/router"; -import type { AssignmentPageData } from "./assignment.data"; import styles from "./assignment-overview.module.scss"; +import type { AssignmentPageData } from "./assignment-types"; type Props = { data: AssignmentPageData; @@ -45,6 +47,29 @@ const AssignmentOverview: Component = (props) => { {props.data.primaryAction}
+ + {(props.data.assignmentAiFeedback || props.data.assignmentTeacherFeedback) && ( +
+
+

Shared assignment feedback

+

{props.data.tutorName}

+
+ + {props.data.assignmentAiFeedback && ( +
+

AI feedback

+

{props.data.assignmentAiFeedback}

+
+ )} + + {props.data.assignmentTeacherFeedback && ( +
+

Teacher feedback

+

{props.data.assignmentTeacherFeedback}

+
+ )} +
+ )} ); }; diff --git a/Frontend/src/routes/assignment/assignment-page.module.scss b/Frontend/src/components/assignment/shared/assignment-page.module.scss similarity index 89% rename from Frontend/src/routes/assignment/assignment-page.module.scss rename to Frontend/src/components/assignment/shared/assignment-page.module.scss index 01410d3..96246b4 100644 --- a/Frontend/src/routes/assignment/assignment-page.module.scss +++ b/Frontend/src/components/assignment/shared/assignment-page.module.scss @@ -1,3 +1,5 @@ +/* Path: Frontend/src/components/assignment/shared/assignment-page.module.scss */ + .page { min-height: 100dvh; padding: 1.25rem; @@ -18,7 +20,7 @@ display: grid; gap: 1.25rem; - @media (min-width: 1080px) { + @include respond(workspace) { grid-template-columns: minmax(0, 1.5fr) minmax(18rem, 0.75fr); align-items: start; } @@ -34,7 +36,7 @@ align-items: center; gap: 0.4rem; padding: 0.35rem; - border-radius: 9999px; + border-radius: var(--radius-full); background: var(--surface-panel); border: 1px solid var(--border-soft); box-shadow: var(--shadow-soft); @@ -52,7 +54,7 @@ align-items: center; justify-content: center; padding: 0.72rem 1rem; - border-radius: 9999px; + border-radius: var(--radius-full); text-decoration: none; color: var(--text-muted); font-weight: 500; @@ -80,7 +82,7 @@ } .sideColumn { - @media (min-width: 1080px) { + @include respond(workspace) { position: sticky; top: 1.25rem; } @@ -90,7 +92,7 @@ display: grid; gap: 0.8rem; padding: 2rem; - border-radius: 1.5rem; + border-radius: var(--radius-3xl); background: var(--surface-panel); border: 1px solid var(--border-soft); box-shadow: var(--shadow-soft); @@ -119,7 +121,7 @@ align-items: center; justify-content: center; padding: 0.85rem 1rem; - border-radius: 9999px; + border-radius: var(--radius-full); background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end)); color: var(--action-primary-text); text-decoration: none; diff --git a/Frontend/src/components/assignment/assignment-question-list.module.scss b/Frontend/src/components/assignment/shared/assignment-question-list.module.scss similarity index 87% rename from Frontend/src/components/assignment/assignment-question-list.module.scss rename to Frontend/src/components/assignment/shared/assignment-question-list.module.scss index 04de8bc..ca5be72 100644 --- a/Frontend/src/components/assignment/assignment-question-list.module.scss +++ b/Frontend/src/components/assignment/shared/assignment-question-list.module.scss @@ -30,7 +30,7 @@ display: grid; gap: 0.85rem; padding: 1.2rem; - border-radius: 1.35rem; + border-radius: var(--radius-2xl); background: var(--surface-panel); border: 1px solid var(--border-soft); box-shadow: var(--shadow-soft); @@ -59,7 +59,7 @@ .statusPill { padding: 0.45rem 0.75rem; - border-radius: 9999px; + border-radius: var(--radius-full); font-size: 0.82rem; font-weight: 600; white-space: nowrap; @@ -87,7 +87,7 @@ span { padding: 0.35rem 0.6rem; - border-radius: 9999px; + border-radius: var(--radius-full); background: var(--surface-panel-strong); border: 1px solid var(--border-divider); font-size: 0.82rem; @@ -96,11 +96,12 @@ } .responseBlock, -.answerKey { +.answerKey, +.supportBlock { display: grid; gap: 0.25rem; padding: 0.95rem 1rem; - border-radius: 1rem; + border-radius: var(--radius-md); background: var(--surface-panel-strong); border: 1px solid var(--border-divider); @@ -116,8 +117,12 @@ font-weight: 600; } - span { + span, + pre { font-size: 0.9rem; color: var(--text-muted); + white-space: pre-wrap; + overflow-wrap: anywhere; + margin: 0; } } diff --git a/Frontend/src/components/assignment/assignment-question-list.tsx b/Frontend/src/components/assignment/shared/assignment-question-list.tsx similarity index 65% rename from Frontend/src/components/assignment/assignment-question-list.tsx rename to Frontend/src/components/assignment/shared/assignment-question-list.tsx index 7538126..0498292 100644 --- a/Frontend/src/components/assignment/assignment-question-list.tsx +++ b/Frontend/src/components/assignment/shared/assignment-question-list.tsx @@ -1,7 +1,9 @@ +// Path: Frontend/src/components/assignment/shared/assignment-question-list.tsx + import type { Component } from "solid-js"; import { For, Show } from "solid-js"; -import type { AssignmentPageData } from "./assignment.data"; import styles from "./assignment-question-list.module.scss"; +import type { AssignmentPageData } from "./assignment-types"; type Props = { data: AssignmentPageData; @@ -11,8 +13,8 @@ const AssignmentQuestionList: Component = (props) => { return (
-

Sample questions

-

{props.data.questions.length} loaded from the mock dataset

+

Question review

+

{props.data.questions.length} questions in this assignment

@@ -32,8 +34,12 @@ const AssignmentQuestionList: Component = (props) => { {question.subTopic} - {question.difficulty} - {question.marks} mark + + {question.difficulty} + + + {question.marks} mark + {question.solveModeLabel} @@ -42,12 +48,21 @@ const AssignmentQuestionList: Component = (props) => {

{question.responseLabel}

{question.responseValue} - {question.feedback} + + {question.feedback} +
+ +
+

Your steps and explanation

+
{question.workingSteps}
+
+
+
-

Answer key

+

Correct answer

{question.correctAnswer}
diff --git a/Frontend/src/components/assignment/shared/assignment-tabs.tsx b/Frontend/src/components/assignment/shared/assignment-tabs.tsx new file mode 100644 index 0000000..b87002c --- /dev/null +++ b/Frontend/src/components/assignment/shared/assignment-tabs.tsx @@ -0,0 +1,39 @@ +// Path: Frontend/src/components/assignment/shared/assignment-tabs.tsx + +import { A, useLocation, useParams } from "@solidjs/router"; +import type { Component } from "solid-js"; +import type { AppRole } from "../../../lib/routes"; +import { getAssignmentReviewHref, getAssignmentWorkHref } from "../../../lib/routes"; +import styles from "./assignment-page.module.scss"; + +type AssignmentTabsProps = { + showWork?: boolean; + role?: AppRole; +}; + +const AssignmentTabs: Component = (props) => { + const params = useParams(); + const location = useLocation(); + const role = () => props.role ?? (location.pathname.includes("/teacher/") ? "teacher" : "student"); + + const reviewHref = () => getAssignmentReviewHref(role(), params.id as string); + const workHref = () => getAssignmentWorkHref(params.id as string); + const isWork = () => location.pathname === workHref(); + + return ( + + ); +}; + +export default AssignmentTabs; diff --git a/Frontend/src/components/assignment/shared/assignment-types.ts b/Frontend/src/components/assignment/shared/assignment-types.ts new file mode 100644 index 0000000..4b7b5b7 --- /dev/null +++ b/Frontend/src/components/assignment/shared/assignment-types.ts @@ -0,0 +1,46 @@ +// Path: Frontend/src/components/assignment/shared/assignment-types.ts + +export type AssignmentPageData = { + id: number; + title: string; + topic: string; + status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED"; + statusLabel: string; + dueLabel: string; + studentName: string; + classroomName: string; + tutorName: string; + headline: string; + description: string; + primaryAction: string; + primaryHref: string; + stats: Array<{ label: string; value: string }>; + coachCard: { + title: string; + description: string; + items: string[]; + }; + assignmentAiFeedback?: string; + assignmentTeacherFeedback?: string; + questions: Array<{ + id: number; + order: number; + prompt: string; + topic: string; + subTopic: string | null; + difficulty: string | null; + marks: number | null; + statusLabel: string; + statusTone: "success" | "warning" | "muted"; + responseLabel: string; + responseValue: string; + feedback: string; + solveModeLabel?: string; + workingSteps?: string; + initialAnswer?: string; + initialSolveMode?: "just_answer" | "step_by_step" | "solve_together" | "handwritten"; + showAnswerKey: boolean; + correctAnswer: string | null; + isCorrect?: boolean | null; + }>; +}; diff --git a/Frontend/src/components/assignment/student/assignment-review.data.ts b/Frontend/src/components/assignment/student/assignment-review.data.ts new file mode 100644 index 0000000..0512a48 --- /dev/null +++ b/Frontend/src/components/assignment/student/assignment-review.data.ts @@ -0,0 +1,204 @@ +// Path: Frontend/src/components/assignment/student/assignment-review.data.ts + +import { apiFetchJson } from "../../../lib/api"; +import type { ApiAssignment, ApiAssignmentStudentQuestionDetail, ApiClassroom, ApiListResponse, ApiUser } from "../../../lib/api-types"; +import { getAssignmentWorkHref } from "../../../lib/routes"; +import type { AssignmentPageData } from "../shared/assignment-types"; + +const formatDateLabel = (value: string | null) => { + if (!value) return "No due date"; + + return new Intl.DateTimeFormat("en-GB", { + weekday: "short", + month: "short", + day: "numeric", + }).format(new Date(value)); +}; + +const extractTopic = (assignment: ApiAssignment, questions: ApiAssignmentStudentQuestionDetail[]) => { + const fromInstructions = assignment.instructions?.match(/^Topic:\s*(.+)$/i)?.[1]?.trim(); + if (fromInstructions) return fromInstructions; + + return questions[0]?.subject ?? "Assignment"; +}; + +const deriveStudentStatus = (questions: ApiAssignmentStudentQuestionDetail[], assignmentStatus: ApiAssignment["status"]) => { + const total = questions.length; + const answered = questions.filter((question) => question.answer_id).length; + const reviewed = questions.filter((question) => question.answer_status === "reviewed").length; + + if (answered === 0) return "NOT_STARTED" as const; + if (reviewed === total || assignmentStatus === "closed") return "SUBMITTED" as const; + return "IN_PROGRESS" as const; +}; + +const deriveStatusLabel = (status: AssignmentPageData["status"]) => { + switch (status) { + case "SUBMITTED": + return "Reviewed"; + case "IN_PROGRESS": + return "In progress"; + default: + return "Not started"; + } +}; + +const deriveHeadline = (status: AssignmentPageData["status"], topic: string) => { + switch (status) { + case "SUBMITTED": + return `Review how you did in ${topic}`; + case "IN_PROGRESS": + return "Keep going — you already have momentum here"; + default: + return "Start this assignment with a steady first pass"; + } +}; + +const deriveDescription = (status: AssignmentPageData["status"], answered: number, reviewed: number, total: number, topic: string) => { + switch (status) { + case "SUBMITTED": + return `${reviewed} of ${total} questions have teacher-reviewed answers. Use this review view to see what landed well and what still needs another pass in ${topic}.`; + case "IN_PROGRESS": + return `You have touched ${answered} of ${total} questions so far. Finish the remaining questions while ${topic.toLowerCase()} still feels familiar.`; + default: + return `This assignment has ${total} questions. Start with a calm first pass, then come back here for one shared review summary across the assignment.`; + } +}; + +const derivePrimaryAction = (status: AssignmentPageData["status"]) => { + switch (status) { + case "SUBMITTED": + return "Open workspace"; + case "IN_PROGRESS": + return "Continue assignment"; + default: + return "Start assignment"; + } +}; + +const mapQuestionStatus = (question: ApiAssignmentStudentQuestionDetail) => { + if (question.is_correct === true) { + return { + statusLabel: "Correct", + statusTone: "success" as const, + }; + } + + if (question.answer_status === "reviewed") { + return { + statusLabel: "Reviewed", + statusTone: "success" as const, + }; + } + + if (question.answer_status === "submitted") { + return { + statusLabel: "Submitted", + statusTone: "warning" as const, + }; + } + + if (question.answer_status === "in_progress") { + return { + statusLabel: "In progress", + statusTone: "warning" as const, + }; + } + + return { + statusLabel: "Not started", + statusTone: "muted" as const, + }; +}; + +export const getAssignmentReviewPageData = async (assignmentId: number, studentId: number): Promise => { + if (!Number.isFinite(assignmentId) || assignmentId <= 0) return null; + + try { + const assignment = await apiFetchJson(`/api/assignments/${assignmentId}`); + + const [student, teacher, classrooms, questionDetails] = await Promise.all([ + apiFetchJson(`/api/users/${studentId}`), + apiFetchJson(`/api/users/${assignment.teacher_id}`), + apiFetchJson>(`/api/teachers/${assignment.teacher_id}/classrooms`), + apiFetchJson>(`/api/assignments/${assignmentId}/students/${studentId}/questions`), + ]); + + const classroom = classrooms.data.find((entry) => entry.id === assignment.classroom_id); + const questions = questionDetails.data; + const topic = extractTopic(assignment, questions); + const answeredCount = questions.filter((question) => question.answer_id).length; + const reviewedCount = questions.filter((question) => question.answer_status === "reviewed").length; + const assignmentAiFeedback = questions[0]?.assignment_ai_feedback?.trim() || ""; + const assignmentTeacherFeedback = questions[0]?.assignment_teacher_feedback?.trim() || ""; + const status = deriveStudentStatus(questions, assignment.status); + const statusLabel = deriveStatusLabel(status); + + return { + id: assignment.id, + title: assignment.title, + topic, + status, + statusLabel, + dueLabel: formatDateLabel(assignment.due_at), + studentName: student.full_name, + classroomName: classroom?.name ?? "Classroom", + tutorName: teacher.full_name, + headline: deriveHeadline(status, topic), + description: deriveDescription(status, answeredCount, reviewedCount, questions.length, topic), + primaryAction: derivePrimaryAction(status), + primaryHref: getAssignmentWorkHref(assignment.id), + stats: [ + { label: "Status", value: statusLabel }, + { label: "Due", value: formatDateLabel(assignment.due_at) }, + { label: "Questions", value: `${questions.length}` }, + { label: status === "SUBMITTED" ? "Reviewed" : "Answered", value: `${status === "SUBMITTED" ? reviewedCount : answeredCount}/${questions.length}` }, + ], + coachCard: { + title: "Review notes", + description: `Use this review view to compare your latest answers with the shared AI and teacher feedback for ${topic.toLowerCase()}.`, + items: [`${answeredCount} questions attempted`, `${reviewedCount} questions reviewed`, `${Math.max(questions.length - answeredCount, 0)} questions still untouched`], + }, + assignmentAiFeedback: assignmentAiFeedback || undefined, + assignmentTeacherFeedback: assignmentTeacherFeedback || undefined, + questions: questions.map((question) => { + const questionStatus = mapQuestionStatus(question); + + return { + id: question.question_id, + order: question.position, + prompt: question.prompt, + topic: question.subject, + subTopic: null, + difficulty: "Backend", + marks: null, + statusLabel: questionStatus.statusLabel, + statusTone: questionStatus.statusTone, + responseLabel: question.answer_id ? "Latest answer" : "Status", + responseValue: question.answer_text?.trim() || "No attempt yet", + feedback: "", + workingSteps: question.working_steps?.trim() || "", + solveModeLabel: + question.solve_mode === "step_by_step" + ? "Step by step" + : question.solve_mode === "solve_together" + ? "Solve together" + : question.solve_mode === "handwritten" + ? "Handwritten" + : question.solve_mode === "just_answer" + ? "Just answer" + : undefined, + showAnswerKey: Boolean(question.correct_answer && question.answer_id), + correctAnswer: question.correct_answer?.trim() || null, + isCorrect: typeof question.is_correct === "boolean" ? question.is_correct : null, + }; + }), + }; + } catch (error) { + if (error instanceof Error && error.message === "not_found") { + return null; + } + + throw error; + } +}; diff --git a/Frontend/src/components/assignment/teacher/assignment-teacher-next-step.tsx b/Frontend/src/components/assignment/teacher/assignment-teacher-next-step.tsx new file mode 100644 index 0000000..61ba5d0 --- /dev/null +++ b/Frontend/src/components/assignment/teacher/assignment-teacher-next-step.tsx @@ -0,0 +1,281 @@ +import type { Component } from "solid-js"; +import { A, useNavigate } from "@solidjs/router"; +import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; +import type { TeacherAssignmentPassStatus, TeacherAssignmentReviewPageData, TeacherNextStepOutcome } from "./assignment-teacher-review.data"; +import { updateAssignmentTeacherFeedback } from "./assignment-teacher-review.data"; +import { AssignmentFeedbackSection, type TeacherReviewNotice } from "./assignment-teacher-review.sections"; +import { getAssignmentReviewHref, getTeacherAssignmentRedoPlanHref } from "../../../lib/routes"; +import styles from "./assignment-teacher-review.module.scss"; + +type Props = { + data: TeacherAssignmentReviewPageData; +}; + +type NextStepDraft = { + teacherFeedback?: string; + decision?: TeacherNextStepOutcome; + passStatusOverride?: TeacherAssignmentPassStatus | null; +}; + +const PASS_STATUS_OVERRIDE_OPTIONS: Array<{ value: TeacherAssignmentPassStatus | null; label: string; help: string }> = [ + { value: null, label: "Automatic", help: "Use the calculated status from the fixed pass rule." }, + { value: "pass", label: "Pass", help: "Override the calculated result and mark this review as pass." }, + { value: "no_pass", label: "No pass", help: "Override the calculated result and mark this review as no pass." }, +]; + +const nextStepStorageKey = (assignmentId: number, studentId: number) => `teacher-next-step-draft:${assignmentId}:${studentId}`; + +const readNextStepDraft = (assignmentId: number, studentId: number): NextStepDraft => { + if (typeof window === "undefined") return {}; + + try { + const raw = window.localStorage.getItem(nextStepStorageKey(assignmentId, studentId)); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? (parsed as NextStepDraft) : {}; + } catch { + return {}; + } +}; + +const writeNextStepDraft = (assignmentId: number, studentId: number, draft: NextStepDraft) => { + if (typeof window === "undefined") return; + const key = nextStepStorageKey(assignmentId, studentId); + if (!draft.teacherFeedback && !draft.decision && draft.passStatusOverride == null) { + window.localStorage.removeItem(key); + return; + } + window.localStorage.setItem(key, JSON.stringify(draft)); +}; + +const OUTCOME_OPTIONS: Array<{ value: TeacherNextStepOutcome; title: string; description: string }> = [ + { value: "redo", title: "Redo assignment", description: "Ask the student to revisit the assignment and try again with the latest review in mind." }, + { value: "accept", title: "Accept and continue", description: "Mark this review as ready to move on and continue the student into the next piece of work." }, + { value: "support", title: "Needs support", description: "Flag that the student needs extra coaching, a follow-up message, or a guided reteach step." }, +]; + +const AssignmentTeacherNextStep: Component = (props) => { + const navigate = useNavigate(); + const [teacherFeedbackDraft, setTeacherFeedbackDraft] = createSignal(""); + const [decisionDraft, setDecisionDraft] = createSignal(null); + const [passStatusOverrideDraft, setPassStatusOverrideDraft] = createSignal(null); + const [savingFeedback, setSavingFeedback] = createSignal(false); + const [notice, setNotice] = createSignal(null); + + const selectedStudentId = createMemo(() => props.data.selectedStudentId); + const canChooseNextStep = createMemo(() => { + return props.data.selectedStudentSubmittedQuestions > 0 || props.data.selectedStudentReviewedQuestions > 0; + }); + const hasPendingAssignmentFeedback = createMemo(() => teacherFeedbackDraft() !== props.data.assignmentTeacherFeedback); + const hasPendingDecision = createMemo(() => decisionDraft() !== props.data.nextStepOutcome); + const hasPendingPassStatusOverride = createMemo(() => passStatusOverrideDraft() !== props.data.passStatusOverride); + const overallScorePercent = createMemo(() => { + if (props.data.overallScore == null) return null; + return Math.round(props.data.overallScore * 10); + }); + + createEffect(() => { + const studentId = selectedStudentId(); + if (!studentId) return; + const draft = readNextStepDraft(props.data.assignmentId, studentId); + setTeacherFeedbackDraft(draft.teacherFeedback ?? props.data.assignmentTeacherFeedback); + setDecisionDraft(draft.decision ?? props.data.nextStepOutcome); + setPassStatusOverrideDraft(draft.passStatusOverride ?? props.data.passStatusOverride); + setNotice(null); + }); + + createEffect(() => { + const studentId = selectedStudentId(); + if (!studentId) return; + writeNextStepDraft(props.data.assignmentId, studentId, { + teacherFeedback: hasPendingAssignmentFeedback() ? teacherFeedbackDraft() : undefined, + decision: hasPendingDecision() ? decisionDraft() ?? undefined : undefined, + passStatusOverride: hasPendingPassStatusOverride() ? passStatusOverrideDraft() : undefined, + }); + }); + + const saveFeedback = async () => { + const studentId = selectedStudentId(); + if (!studentId) return; + if (!canChooseNextStep()) { + setNotice({ + scope: "assignment", + tone: "error", + text: "Next step stays locked until the student has submitted work for review.", + }); + return; + } + + setSavingFeedback(true); + setNotice(null); + try { + if (hasPendingAssignmentFeedback() || hasPendingPassStatusOverride() || hasPendingDecision()) { + await updateAssignmentTeacherFeedback(props.data.assignmentId, studentId, { + teacherFeedback: teacherFeedbackDraft().trim(), + passStatusOverride: passStatusOverrideDraft(), + nextStepOutcome: decisionDraft(), + }); + } + + writeNextStepDraft(props.data.assignmentId, studentId, {}); + if (decisionDraft() === "redo") { + navigate(getTeacherAssignmentRedoPlanHref(props.data.assignmentId, studentId)); + return; + } + navigate(getAssignmentReviewHref("teacher", props.data.assignmentId)); + } catch (error) { + setNotice({ + scope: "assignment", + tone: "error", + text: error instanceof Error ? error.message : "Could not save teacher feedback right now.", + }); + } finally { + setSavingFeedback(false); + } + }; + + return ( +
+
+
+
+

Choose the next step

+

+ + {props.data.selectedStudentName} has been reviewed. Choose what should happen next, then save your feedback at the bottom before returning to the review page. + +

+
+ + Pick a student from the review queue first so you can set the right next step.
}> + This student has not submitted work yet, so next step is not available.
} + > +
+
+

Recommended outcome

+

Pick the next teaching move for {props.data.selectedStudentName ?? "this student"}. The selected outcome will be saved with this student's review.

+
+ +
+ + {(option) => ( + + )} + +
+ +
+ No next-step outcome has been picked yet.}> + Selected outcome: {OUTCOME_OPTIONS.find((option) => option.value === decisionDraft())?.title}. Save feedback to persist it for this student. + +
+ +
+
+ +
+ Pending review}> + {overallScorePercent()}% + +
+ + + Blended correctness and understanding score. + + +
+ +
+ + + + + Teacher override is active. + + +
+ +
+ + + {PASS_STATUS_OVERRIDE_OPTIONS.find((option) => option.value === passStatusOverrideDraft())?.help ?? PASS_STATUS_OVERRIDE_OPTIONS[0]!.help} +
+
+
+ + + {savingFeedback() + ? decisionDraft() === "redo" + ? "Saving and opening redo plan..." + : "Saving and returning..." + : decisionDraft() === "redo" + ? "Save feedback and open redo plan" + : "Save feedback and return to review"} + + } + /> + + +
+ + + + + ); +}; + +export default AssignmentTeacherNextStep; diff --git a/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.helpers.ts b/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.helpers.ts new file mode 100644 index 0000000..4473953 --- /dev/null +++ b/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.helpers.ts @@ -0,0 +1,107 @@ +import { createTeacherAssignment, generateTeacherQuestions } from "../../dashboard/teacher/dashboard-teacher-assignments.data"; +import type { TeacherRedoPlanData, TeacherRedoPlanQuestion } from "./assignment-teacher-review.types"; +import type { RedoPlanGenerationResult, RedoPlanGroupedItem } from "./assignment-teacher-redo-plan.types"; + +const compareGroupedItems = (left: RedoPlanGroupedItem, right: RedoPlanGroupedItem) => { + if (left.topic !== right.topic) return left.topic.localeCompare(right.topic); + return left.difficulty.localeCompare(right.difficulty); +}; + +export const groupRedoPlanQuestions = (questionSet: TeacherRedoPlanQuestion[]): RedoPlanGroupedItem[] => { + const groupedItems = new Map< + string, + { + topic: string; + difficulty: "easy" | "medium" | "hard"; + count: number; + tags: Set; + reasons: string[]; + } + >(); + + for (const item of questionSet) { + const key = `${item.topicKey}::${item.difficultyKey}`; + const existing = groupedItems.get(key); + if (existing) { + existing.count += 1; + item.tags.forEach((tag) => existing.tags.add(tag)); + existing.reasons.push(item.reason); + continue; + } + + groupedItems.set(key, { + topic: item.topicKey, + difficulty: item.difficultyKey, + count: 1, + tags: new Set(item.tags), + reasons: [item.reason], + }); + } + + return Array.from(groupedItems.values()) + .map((item) => ({ + topic: item.topic, + difficulty: item.difficulty, + count: item.count, + tags: Array.from(item.tags), + reasons: item.reasons, + })) + .sort(compareGroupedItems); +}; + +export const buildPlannedAreasMarkdown = (groupedItems: RedoPlanGroupedItem[]) => + groupedItems + .map( + (item, index) => + `- ${index + 1}. ${item.topic.replace(/_/g, " ")} (${item.difficulty}) x${item.count}${item.reasons.length > 0 ? ` — ${item.reasons.join("; ")}` : ""}`, + ) + .join("\n"); + +export const createRedoAssignmentForStudent = async (input: { + data: TeacherRedoPlanData; + teacherId: number; +}): Promise => { + const groupedItems = groupRedoPlanQuestions(input.data.plan?.questionSet ?? []); + const plannedAreas = buildPlannedAreasMarkdown(groupedItems); + const generatedQuestionIds: number[] = []; + const generatedDescriptions: string[] = []; + + for (const item of groupedItems) { + const result = await generateTeacherQuestions({ + topic: item.topic, + difficulty: item.difficulty, + count: item.count, + source: "redo_plan_generated", + }); + + generatedQuestionIds.push(...result.generatedQuestionIds); + generatedDescriptions.push(`${item.topic.replace(/_/g, " ")} ${item.difficulty} ×${result.count} (seed ${result.seed})`); + } + + const assignment = await createTeacherAssignment({ + teacherId: input.teacherId, + classroomId: input.data.classroomId, + title: `${input.data.selectedStudentName} redo • ${input.data.title}`, + instructions: [ + `Student-specific redo follow-up for ${input.data.selectedStudentName}.`, + `Source assignment: ${input.data.title}`, + "", + `Plan rationale: ${input.data.plan?.rationale ?? ""}`, + input.data.teacherFeedback ? `Teacher feedback: ${input.data.teacherFeedback}` : "", + input.data.weaknessSummary.weakTags.length > 0 ? `Weak tags: ${input.data.weaknessSummary.weakTags.join(", ")}` : "", + "", + "Planned focus areas:", + plannedAreas, + ] + .filter(Boolean) + .join("\n"), + dueAt: "", + selectedQuestionIds: Array.from(new Set(generatedQuestionIds)), + assignedStudentIds: [input.data.selectedStudentId], + }); + + return { + assignmentId: assignment.id, + successMessage: `Created a redo assignment for ${input.data.selectedStudentName}. ${generatedDescriptions.join("; ")}.`, + }; +}; diff --git a/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.sections.tsx b/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.sections.tsx new file mode 100644 index 0000000..d14c245 --- /dev/null +++ b/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.sections.tsx @@ -0,0 +1,177 @@ +import type { Component } from "solid-js"; +import { A } from "@solidjs/router"; +import { For, Show } from "solid-js"; +import { getAssignmentReviewHref, getTeacherAssignmentNextStepHref } from "../../../lib/routes"; +import type { TeacherRedoPlanData } from "./assignment-teacher-review.types"; +import styles from "./assignment-teacher-review.module.scss"; + +type RedoPlanMainColumnProps = { + data: TeacherRedoPlanData; +}; + +export const RedoPlanMainColumn: Component = (props) => ( +
+
+
+

Redo plan

+

+ AI prepared this redo plan for {props.data.selectedStudentName} based on the completed review, weakness summary, and teacher feedback. +

+
+ + +
{props.data.error}
+
+ + No redo plan is available yet. Save the next-step page with Redo assignment selected to generate one.
} + > +
+
+

Rationale

+

Why this student needs the planned mix of follow-up practice.

+
+
+ {props.data.plan!.rationale} +
+
+ +
+
+

Planned question set

+

These blueprint slices will be turned into a student-specific redo assignment when you generate it.

+
+
+ + {(item, index) => ( +
+
+
+

Item {index() + 1}

+

{item.topic}

+
+ {item.difficulty} +
+ 0}> +
+ {(tag) => {tag}} +
+
+
+

Reason

+ {item.reason} +
+
+ )} +
+
+
+ + + +); + +type RedoPlanSidebarProps = { + data: TeacherRedoPlanData; + isCreatingRedoAssignment: boolean; + generationError: string | null; + generationSuccess: string | null; + onGenerateRedoQuestions: () => void; +}; + +export const RedoPlanSidebar: Component = (props) => ( + +); diff --git a/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.tsx b/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.tsx new file mode 100644 index 0000000..abdcb56 --- /dev/null +++ b/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.tsx @@ -0,0 +1,59 @@ +import type { Component } from "solid-js"; +import { useNavigate } from "@solidjs/router"; +import { createSignal } from "solid-js"; +import { useAuth } from "~/context/auth/context"; +import { getAssignmentReviewHref } from "../../../lib/routes"; +import { createRedoAssignmentForStudent } from "./assignment-teacher-redo-plan.helpers"; +import { RedoPlanMainColumn, RedoPlanSidebar } from "./assignment-teacher-redo-plan.sections"; +import type { AssignmentTeacherRedoPlanProps } from "./assignment-teacher-redo-plan.types"; +import styles from "./assignment-teacher-review.module.scss"; + +const AssignmentTeacherRedoPlan: Component = (props) => { + const auth = useAuth(); + const navigate = useNavigate(); + const [isCreatingRedoAssignment, setIsCreatingRedoAssignment] = createSignal(false); + const [generationError, setGenerationError] = createSignal(null); + const [generationSuccess, setGenerationSuccess] = createSignal(null); + + const handleGenerateRedoQuestions = async () => { + if (!props.data.plan?.questionSet.length) return; + const teacherId = auth.user()?.role === "teacher" ? auth.user()!.id : null; + if (!teacherId) { + setGenerationError("Your teacher session is still loading."); + return; + } + + setIsCreatingRedoAssignment(true); + setGenerationError(null); + setGenerationSuccess(null); + + try { + const result = await createRedoAssignmentForStudent({ + data: props.data, + teacherId, + }); + + setGenerationSuccess(result.successMessage); + void navigate(getAssignmentReviewHref("teacher", result.assignmentId)); + } catch (error) { + setGenerationError(error instanceof Error ? error.message : "Unable to create the redo assignment right now."); + } finally { + setIsCreatingRedoAssignment(false); + } + }; + + return ( +
+ + void handleGenerateRedoQuestions()} + /> +
+ ); +}; + +export default AssignmentTeacherRedoPlan; diff --git a/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.types.ts b/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.types.ts new file mode 100644 index 0000000..e9b43ac --- /dev/null +++ b/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.types.ts @@ -0,0 +1,18 @@ +import type { TeacherRedoPlanData } from "./assignment-teacher-review.types"; + +export type AssignmentTeacherRedoPlanProps = { + data: TeacherRedoPlanData; +}; + +export type RedoPlanGroupedItem = { + topic: string; + difficulty: "easy" | "medium" | "hard"; + count: number; + tags: string[]; + reasons: string[]; +}; + +export type RedoPlanGenerationResult = { + assignmentId: number; + successMessage: string; +}; diff --git a/Frontend/src/components/assignment/teacher/assignment-teacher-review.data.ts b/Frontend/src/components/assignment/teacher/assignment-teacher-review.data.ts new file mode 100644 index 0000000..45d68d8 --- /dev/null +++ b/Frontend/src/components/assignment/teacher/assignment-teacher-review.data.ts @@ -0,0 +1,246 @@ +// Path: Frontend/src/components/assignment/teacher/assignment-teacher-review.data.ts + +import { apiFetchJson } from "../../../lib/api"; +import type { + ApiAssignment, + ApiAssignmentRedoPlanResponse, + ApiAssignmentStudentQuestionDetail, + ApiClassroom, + ApiListResponse, + ApiReviewQueueItem, + ApiReviewSummary, +} from "../../../lib/api-types"; +import { + buildCloseSummary, + buildQuestionTags, + extractTopic, + formatDateLabel, + formatRelativeLabel, + formatSolveMode, + mapQueueReviewStatus, + mapRedoPlan, + mapTopicScores, + normalizeScore, + questionStatus, + queueStatusLabel, + queueTone, +} from "./assignment-teacher-review.formatters"; +import type { + TeacherAssignmentPassStatus, + TeacherAssignmentReviewPageData, + TeacherNextStepOutcome, + TeacherRedoPlanData, + TeacherReviewDraftFields, + TeacherReviewQuestion, +} from "./assignment-teacher-review.types"; + +type UpdateTeacherAnswerReviewInput = TeacherReviewDraftFields & { + status: "not_started" | "in_progress" | "submitted" | "reviewed"; + reviewTags: string[]; +}; + +type UpdateAssignmentTeacherFeedbackInput = { + teacherFeedback: string; + passStatusOverride: TeacherAssignmentPassStatus | null; + nextStepOutcome: TeacherNextStepOutcome | null; +}; + +export const getTeacherAssignmentReviewPageData = async (assignmentId: number, selectedStudentId?: number | null): Promise => { + if (!Number.isFinite(assignmentId) || assignmentId <= 0) return null; + + try { + const assignment = await apiFetchJson(`/api/assignments/${assignmentId}`); + const [classrooms, summary, queueResponse] = await Promise.all([ + apiFetchJson>(`/api/teachers/${assignment.teacher_id}/classrooms`), + apiFetchJson(`/api/assignments/${assignmentId}/review-summary`), + apiFetchJson>(`/api/assignments/${assignmentId}/review`), + ]); + + const classroom = classrooms.data.find((entry) => entry.id === assignment.classroom_id); + const queue = queueResponse.data; + const resolvedStudentId = selectedStudentId ?? queue[0]?.student_id ?? null; + const selectedStudent = queue.find((item) => item.student_id === resolvedStudentId) ?? null; + const questionDetails = resolvedStudentId + ? await apiFetchJson>(`/api/assignments/${assignmentId}/students/${resolvedStudentId}/questions`) + : { data: [] as ApiAssignmentStudentQuestionDetail[] }; + const questions = questionDetails.data; + const assignmentAiFeedback = questions[0]?.assignment_ai_feedback?.trim() || ""; + const assignmentTeacherFeedback = questions[0]?.assignment_teacher_feedback?.trim() || ""; + const overallScore = normalizeScore(questions[0]?.overall_score); + const passThreshold = + typeof assignment.pass_threshold === "number" + ? assignment.pass_threshold + : typeof questions[0]?.pass_threshold === "number" + ? questions[0]!.pass_threshold! + : 6; + const nextStepOutcome = (questions[0]?.next_step_outcome as TeacherNextStepOutcome | undefined) ?? null; + const passStatusOverride = (questions[0]?.pass_status_override as TeacherAssignmentPassStatus | undefined) ?? null; + const passStatus = (questions[0]?.pass_status as TeacherAssignmentPassStatus | undefined) ?? "pending"; + const topic = extractTopic(assignment, questions); + const reviewCoverage = summary.total_assigned > 0 ? Math.round((summary.reviewed / summary.total_assigned) * 100) : 0; + + return { + assignmentId: assignment.id, + title: assignment.title, + classroomId: assignment.classroom_id, + classroomName: classroom?.name ?? "Classroom", + statusLabel: assignment.status === "draft" ? "Draft" : assignment.status === "closed" ? "Closed" : "Live", + headline: selectedStudent ? `Review ${selectedStudent.student_name}'s responses for ${topic}` : `Review submissions for ${topic}`, + description: selectedStudent + ? `${selectedStudent.student_name} has ${selectedStudent.answered_questions} answered question${selectedStudent.answered_questions === 1 ? "" : "s"}. Flag any response that still needs attention, then move to the next step when the rest is ready.` + : "Choose a student from the review queue to inspect answers, working steps, and feedback status for this assignment.", + dueLabel: formatDateLabel(assignment.due_at), + stats: [ + { label: "Assigned", value: `${summary.total_assigned}` }, + { label: "Submitted", value: `${summary.submitted}` }, + { label: "Reviewed", value: `${summary.reviewed}` }, + { label: "Coverage", value: `${reviewCoverage}%` }, + ], + closeSummary: buildCloseSummary(assignment.status, queue), + reviewQueue: queue.map((item) => ({ + studentId: item.student_id, + studentName: item.student_name, + email: item.student_email, + reviewStatus: item.review_status, + nextStepOutcome: mapQueueReviewStatus(item.next_step_outcome), + submittedQuestions: item.submitted_questions, + reviewedQuestions: item.reviewed_questions, + progressLabel: `${item.reviewed_questions}/${item.total_questions} reviewed · ${item.answered_questions} answered`, + statusLabel: queueStatusLabel(item), + statusTone: queueTone(item), + timestampLabel: formatRelativeLabel(item.latest_submitted_at ?? item.latest_reviewed_at, "No submission yet"), + })), + selectedStudentId: selectedStudent?.student_id ?? null, + selectedStudentName: selectedStudent?.student_name ?? null, + selectedStudentEmail: selectedStudent?.student_email ?? null, + selectedStudentProgress: selectedStudent ? `${selectedStudent.reviewed_questions} of ${selectedStudent.total_questions} reviewed · ${selectedStudent.submitted_questions} waiting` : null, + selectedStudentReviewStatus: selectedStudent?.review_status ?? null, + selectedStudentSubmittedQuestions: selectedStudent?.submitted_questions ?? 0, + selectedStudentReviewedQuestions: selectedStudent?.reviewed_questions ?? 0, + assignmentAiFeedback, + assignmentTeacherFeedback, + overallScore, + passThreshold, + nextStepOutcome, + passStatusOverride, + passStatus, + coachCard: { + title: selectedStudent ? "Teacher review notes" : "Review queue guidance", + description: selectedStudent + ? "Flag only the responses that still need attention. Moving to the next step will treat everything else as reviewed." + : "Start with the freshest submitted work, then move through the queue in order of urgency.", + items: [`${summary.submitted} submission${summary.submitted === 1 ? "" : "s"} waiting`, `${summary.in_progress} in progress`, `${summary.not_started} not started`], + }, + questions: questions.map((question): TeacherReviewQuestion => { + const status = questionStatus(question); + return { + id: question.question_id, + order: question.position, + prompt: question.prompt, + subject: question.subject, + source: question.source, + questionTags: buildQuestionTags(question), + answerId: question.answer_id ?? null, + answerStatus: (question.answer_status as TeacherReviewQuestion["answerStatus"]) ?? null, + statusLabel: status.statusLabel, + statusTone: status.statusTone, + answerText: question.answer_text?.trim() || "No answer has been submitted for this question yet.", + solveModeLabel: formatSolveMode(question.solve_mode), + workingSteps: question.working_steps?.trim() || "", + correctAnswer: question.correct_answer?.trim() || null, + isCorrect: typeof question.is_correct === "boolean" ? question.is_correct : null, + reviewNeedsAttention: Boolean(question.review_needs_attention), + reviewIssueReason: question.review_issue_reason?.trim() || "", + reviewCorrectnessScore: normalizeScore(question.review_correctness_score), + reviewUnderstandingScore: normalizeScore(question.review_understanding_score), + reviewQuestionScore: normalizeScore(question.review_question_score), + reviewConfidence: normalizeScore(question.review_confidence), + correctnessLabel: + typeof question.is_correct === "boolean" + ? question.is_correct + ? "Matches the saved correct answer" + : "Does not match the saved correct answer yet" + : question.correct_answer + ? "Correct answer is available for comparison" + : "No correct answer saved yet", + submittedLabel: formatDateLabel(question.submitted_at, "Not submitted"), + reviewedLabel: formatDateLabel(question.reviewed_at, "Not reviewed yet"), + }; + }), + }; + } catch (error) { + if (error instanceof Error && error.message === "not_found") { + return null; + } + throw error; + } +}; + +export const updateTeacherAnswerReview = async (answerId: number, input: UpdateTeacherAnswerReviewInput) => { + return await apiFetchJson(`/api/answers/${answerId}/review`, { + method: "PATCH", + allowNoContent: true, + body: JSON.stringify({ + status: input.status, + review_needs_attention: input.reviewNeedsAttention, + review_issue_reason: input.reviewIssueReason || null, + review_correctness_score: input.reviewCorrectnessScore, + review_understanding_score: input.reviewUnderstandingScore, + review_question_score: input.reviewQuestionScore, + review_confidence: input.reviewConfidence, + review_tags: input.reviewTags, + }), + }); +}; + +export const updateAssignmentTeacherFeedback = async (assignmentId: number, studentId: number, input: UpdateAssignmentTeacherFeedbackInput) => { + return await apiFetchJson(`/api/assignments/${assignmentId}/students/${studentId}/feedback`, { + method: "PATCH", + allowNoContent: true, + body: JSON.stringify({ + teacher_feedback: input.teacherFeedback, + pass_status_override: input.passStatusOverride, + next_step_outcome: input.nextStepOutcome, + }), + }); +}; + +export const closeTeacherAssignment = async (assignmentId: number) => { + return await apiFetchJson(`/api/assignments/${assignmentId}/close`, { + method: "POST", + parseErrorMessage: true, + }); +}; + +export const getTeacherAssignmentRedoPlanData = async (assignmentId: number, studentId: number): Promise => { + if (!Number.isFinite(assignmentId) || assignmentId <= 0 || !Number.isFinite(studentId) || studentId <= 0) return null; + + const [reviewData, redoPlan] = await Promise.all([ + getTeacherAssignmentReviewPageData(assignmentId, studentId), + apiFetchJson(`/api/assignments/${assignmentId}/students/${studentId}/redo-plan`, { notFoundAsNull: true }), + ]); + + if (!reviewData || !redoPlan || !reviewData.selectedStudentId || !reviewData.selectedStudentName || !reviewData.selectedStudentEmail) { + return null; + } + + return { + assignmentId: reviewData.assignmentId, + title: reviewData.title, + classroomId: reviewData.classroomId, + classroomName: reviewData.classroomName, + selectedStudentId: reviewData.selectedStudentId, + selectedStudentName: reviewData.selectedStudentName, + selectedStudentEmail: reviewData.selectedStudentEmail, + teacherFeedback: redoPlan.teacher_feedback?.trim() || reviewData.assignmentTeacherFeedback, + generatedAtLabel: redoPlan.redo_plan_generated_at ? formatRelativeLabel(redoPlan.redo_plan_generated_at) : null, + weaknessSummary: { + studentId: redoPlan.weakness_summary.student_id, + topicScores: mapTopicScores(redoPlan.weakness_summary), + weakTags: Array.isArray(redoPlan.weakness_summary.weak_tags) ? redoPlan.weakness_summary.weak_tags.filter(Boolean) : [], + recentIssues: Array.isArray(redoPlan.weakness_summary.recent_issues) ? redoPlan.weakness_summary.recent_issues.filter(Boolean) : [], + }, + plan: mapRedoPlan(redoPlan.plan), + error: redoPlan.error?.trim() || null, + }; +}; diff --git a/Frontend/src/components/assignment/teacher/assignment-teacher-review.drafts.ts b/Frontend/src/components/assignment/teacher/assignment-teacher-review.drafts.ts new file mode 100644 index 0000000..28311ce --- /dev/null +++ b/Frontend/src/components/assignment/teacher/assignment-teacher-review.drafts.ts @@ -0,0 +1,116 @@ +import type { TeacherReviewDraftFields, TeacherReviewQuestion } from "./assignment-teacher-review.types"; +import { assignmentUiCopy } from "~/content/ui-copy"; + +export type QuestionReviewDraft = TeacherReviewDraftFields & { + answerId: number; +}; + +export type StudentReviewDraft = { + questionReviews?: Record; +}; + +export type AssignmentReviewDraftStore = { + students: Record; +}; + +export const EMPTY_DRAFT_STORE: AssignmentReviewDraftStore = { + students: {}, +}; + +export const draftStorageKeyForAssignment = (assignmentId: number) => `teacher-review-draft:${assignmentId}`; + +export const readDraftStore = (assignmentId: number): AssignmentReviewDraftStore => { + if (typeof window === "undefined") return EMPTY_DRAFT_STORE; + + try { + const raw = window.localStorage.getItem(draftStorageKeyForAssignment(assignmentId)); + if (!raw) return EMPTY_DRAFT_STORE; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || typeof parsed.students !== "object" || parsed.students === null) { + return EMPTY_DRAFT_STORE; + } + + return { + students: parsed.students as AssignmentReviewDraftStore["students"], + }; + } catch { + return EMPTY_DRAFT_STORE; + } +}; + +export const writeDraftStore = (assignmentId: number, draftStore: AssignmentReviewDraftStore) => { + if (typeof window === "undefined") return; + const key = draftStorageKeyForAssignment(assignmentId); + if (Object.keys(draftStore.students).length === 0) { + window.localStorage.removeItem(key); + return; + } + + window.localStorage.setItem(key, JSON.stringify(draftStore)); +}; + +export const reviewFieldsFromQuestion = (question: TeacherReviewQuestion): TeacherReviewDraftFields => ({ + reviewNeedsAttention: question.reviewNeedsAttention, + reviewIssueReason: question.reviewIssueReason, + reviewCorrectnessScore: question.reviewCorrectnessScore, + reviewUnderstandingScore: question.reviewUnderstandingScore, + reviewQuestionScore: question.reviewQuestionScore, + reviewConfidence: question.reviewConfidence, +}); + +const normalizeOptionalScore = (value: number | null) => (typeof value === "number" && Number.isFinite(value) ? Math.min(1, Math.max(0, Number(value.toFixed(3)))) : null); + +export const sanitizeReviewFields = (fields: TeacherReviewDraftFields): TeacherReviewDraftFields => ({ + reviewNeedsAttention: Boolean(fields.reviewNeedsAttention), + reviewIssueReason: fields.reviewIssueReason.trim(), + reviewCorrectnessScore: 1, + reviewUnderstandingScore: normalizeOptionalScore(fields.reviewUnderstandingScore), + reviewQuestionScore: 1, + reviewConfidence: normalizeOptionalScore(fields.reviewConfidence), +}); + +export const reviewFieldsEqual = (left: TeacherReviewDraftFields, right: TeacherReviewDraftFields) => + left.reviewNeedsAttention === right.reviewNeedsAttention && + left.reviewIssueReason === right.reviewIssueReason && + left.reviewCorrectnessScore === right.reviewCorrectnessScore && + left.reviewUnderstandingScore === right.reviewUnderstandingScore && + left.reviewQuestionScore === right.reviewQuestionScore && + left.reviewConfidence === right.reviewConfidence; + +const questionStatusMeta = (status: TeacherReviewQuestion["answerStatus"]) => { + if (status === "reviewed") { + return { statusLabel: assignmentUiCopy.teacherReview.status.reviewed, statusTone: "success" as const }; + } + if (status === "submitted") { + return { statusLabel: assignmentUiCopy.teacherReview.status.submitted, statusTone: "review" as const }; + } + if (status === "in_progress") { + return { statusLabel: assignmentUiCopy.teacherReview.status.inProgress, statusTone: "progress" as const }; + } + return { statusLabel: assignmentUiCopy.teacherReview.status.noAnswerYet, statusTone: "muted" as const }; +}; + +export const applyReviewDrafts = (questions: TeacherReviewQuestion[], studentDraft: StudentReviewDraft | null) => + questions.map((question) => { + const draftReview = studentDraft?.questionReviews?.[String(question.id)] ?? null; + if (!draftReview) return question; + + const effectiveReview = sanitizeReviewFields({ + ...reviewFieldsFromQuestion(question), + ...draftReview, + }); + const nextStatus = effectiveReview.reviewNeedsAttention ? "submitted" : question.answerStatus; + const statusMeta = questionStatusMeta(nextStatus); + + return { + ...question, + ...effectiveReview, + answerStatus: nextStatus, + statusLabel: effectiveReview.reviewNeedsAttention ? assignmentUiCopy.teacherReview.status.needsAttention : statusMeta.statusLabel, + statusTone: statusMeta.statusTone, + reviewedLabel: effectiveReview.reviewNeedsAttention ? assignmentUiCopy.teacherReview.status.needsAttentionDraft : question.reviewedLabel, + }; + }); + +export const countPendingQuestionDrafts = (draftStore: AssignmentReviewDraftStore) => + Object.values(draftStore.students).reduce((count, studentDraft) => count + Object.keys(studentDraft.questionReviews ?? {}).length, 0); diff --git a/Frontend/src/components/assignment/teacher/assignment-teacher-review.formatters.ts b/Frontend/src/components/assignment/teacher/assignment-teacher-review.formatters.ts new file mode 100644 index 0000000..8726936 --- /dev/null +++ b/Frontend/src/components/assignment/teacher/assignment-teacher-review.formatters.ts @@ -0,0 +1,194 @@ +import type { + ApiAssignment, + ApiAssignmentStudentQuestionDetail, + ApiRedoPlan, + ApiRedoPlanQuestion, + ApiReviewQueueItem, + ApiStudentWeaknessSummary, +} from "../../../lib/api-types"; +import type { + TeacherAssignmentCloseSummary, + TeacherNextStepOutcome, + TeacherRedoPlanQuestion, + TeacherReviewQueueItem, +} from "./assignment-teacher-review.types"; +import { assignmentUiCopy } from "~/content/ui-copy"; + +export const formatDateLabel = (value: string | null | undefined, fallback = "No due date") => { + if (!value) return fallback; + + return new Intl.DateTimeFormat("en-GB", { + weekday: "short", + month: "short", + day: "numeric", + }).format(new Date(value)); +}; + +export const formatRelativeLabel = (value: string | null | undefined, fallback = "No recent update") => { + if (!value) return fallback; + + const timestamp = new Date(value).getTime(); + if (Number.isNaN(timestamp)) return fallback; + + const diffMinutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000)); + if (diffMinutes < 1) return "Just now"; + if (diffMinutes < 60) return `${diffMinutes} min ago`; + const diffHours = Math.round(diffMinutes / 60); + if (diffHours < 24) return `${diffHours} hr ago`; + const diffDays = Math.round(diffHours / 24); + return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`; +}; + +export const formatSolveMode = (value: ApiAssignmentStudentQuestionDetail["solve_mode"]) => { + switch (value) { + case "step_by_step": + return "Step by step"; + case "solve_together": + return "Solve together"; + case "handwritten": + return "Handwritten"; + case "just_answer": + return "Just answer"; + default: + return null; + } +}; + +export const extractTopic = (assignment: ApiAssignment, questions: ApiAssignmentStudentQuestionDetail[]) => { + const fromInstructions = assignment.instructions?.match(/^Topic:\s*(.+)$/im)?.[1]?.trim(); + if (fromInstructions) return fromInstructions; + return questions[0]?.subject ?? "this assignment"; +}; + +export const queueTone = (item: ApiReviewQueueItem): TeacherReviewQueueItem["statusTone"] => { + if (item.review_status === "submitted") return "review"; + if (item.review_status === "in_progress" || item.reviewed_questions > 0 || item.answered_questions > 0) return "progress"; + if (item.next_step_outcome === "accept") return "success"; + if (item.next_step_outcome === "redo" || item.next_step_outcome === "support") return "review"; + return "muted"; +}; + +export const queueStatusLabel = (item: ApiReviewQueueItem) => { + if (item.review_status === "submitted") return "Submitted"; + if (item.review_status === "in_progress" || item.reviewed_questions > 0 || item.answered_questions > 0) return "In progress"; + if (item.next_step_outcome === "redo") return "Redo"; + if (item.next_step_outcome === "accept") return "Accept"; + if (item.next_step_outcome === "support") return "Support"; + return "Not started"; +}; + +export const buildCloseSummary = (assignmentStatus: ApiAssignment["status"], queue: ApiReviewQueueItem[]): TeacherAssignmentCloseSummary => { + if (assignmentStatus === "closed") { + return { + state: "closed", + canClose: false, + blockers: [], + summary: "This assignment is already closed.", + }; + } + + if (queue.length === 0) { + return { + state: "blocked", + canClose: false, + blockers: ["No students have been assigned yet."], + summary: "Assign at least one student before closing this assignment.", + }; + } + + const blockers = queue.flatMap((item) => { + if (item.submitted_questions > 0 || item.review_status === "submitted") { + return [`${item.student_name} still has submitted work waiting for review.`]; + } + + if (item.in_progress_questions > 0 || item.review_status === "in_progress") { + return [`${item.student_name} still has work in progress.`]; + } + + if (item.answered_questions === 0 || item.review_status === "not_started") { + return [`${item.student_name} has not started this assignment yet.`]; + } + + if (!item.next_step_outcome) { + return [`${item.student_name} still needs a next-step decision.`]; + } + + return [] as string[]; + }); + + if (blockers.length > 0) { + return { + state: "blocked", + canClose: false, + blockers, + summary: `${blockers.length} blocker${blockers.length === 1 ? "" : "s"} still need attention before this assignment can be closed.`, + }; + } + + return { + state: "ready", + canClose: true, + blockers: [], + summary: "All assigned students have been reviewed and given a next-step decision. This assignment is ready to close.", + }; +}; + +export const questionStatus = (question: ApiAssignmentStudentQuestionDetail) => { + if (question.review_needs_attention) { + return { statusLabel: assignmentUiCopy.teacherReview.status.needsAttention, statusTone: "review" as const }; + } + if (question.answer_status === "reviewed") { + return { statusLabel: assignmentUiCopy.teacherReview.status.reviewed, statusTone: "success" as const }; + } + if (question.answer_status === "submitted") { + return { statusLabel: assignmentUiCopy.teacherReview.status.submitted, statusTone: "review" as const }; + } + if (question.answer_status === "in_progress") { + return { statusLabel: assignmentUiCopy.teacherReview.status.inProgress, statusTone: "progress" as const }; + } + return { statusLabel: assignmentUiCopy.teacherReview.status.noAnswerYet, statusTone: "muted" as const }; +}; + +export const normalizeScore = (value: number | null | undefined) => (typeof value === "number" && Number.isFinite(value) ? value : null); + +export const normalizeTags = (value: string[] | null | undefined) => + Array.isArray(value) + ? value.map((tag) => tag.trim()).filter(Boolean) + : []; + +export const buildQuestionTags = (question: ApiAssignmentStudentQuestionDetail) => { + const tags = [...normalizeTags(question.question_tags), question.subject?.trim() || "", formatSolveMode(question.solve_mode) || ""].filter(Boolean); + return Array.from(new Set(tags)); +}; + +export const formatTopicLabel = (value: string) => + value + .split("_") + .map((part) => (part ? part[0]!.toUpperCase() + part.slice(1) : part)) + .join(" "); + +export const formatDifficultyLabel = (value: string) => (value ? value[0]!.toUpperCase() + value.slice(1).toLowerCase() : value); + +export const mapTopicScores = (summary: ApiStudentWeaknessSummary) => + Object.entries(summary.topic_scores ?? {}) + .map(([topic, score]) => ({ topic: formatTopicLabel(topic), score })) + .sort((left, right) => left.score - right.score || left.topic.localeCompare(right.topic)); + +export const mapRedoPlanQuestion = (item: ApiRedoPlanQuestion): TeacherRedoPlanQuestion => ({ + topic: formatTopicLabel(item.topic), + topicKey: item.topic, + difficulty: formatDifficultyLabel(item.difficulty), + difficultyKey: item.difficulty, + tags: Array.isArray(item.tags) ? item.tags.filter(Boolean) : [], + reason: item.reason?.trim() || "No reason provided.", +}); + +export const mapRedoPlan = (plan: ApiRedoPlan | null | undefined) => { + if (!plan) return null; + return { + rationale: plan.rationale?.trim() || "No rationale provided.", + questionSet: Array.isArray(plan.questionSet) ? plan.questionSet.map(mapRedoPlanQuestion) : [], + }; +}; + +export const mapQueueReviewStatus = (value: ApiReviewQueueItem["next_step_outcome"]): TeacherNextStepOutcome | null => value ?? null; diff --git a/Frontend/src/components/assignment/teacher/assignment-teacher-review.module.scss b/Frontend/src/components/assignment/teacher/assignment-teacher-review.module.scss new file mode 100644 index 0000000..ac3cfe3 --- /dev/null +++ b/Frontend/src/components/assignment/teacher/assignment-teacher-review.module.scss @@ -0,0 +1,824 @@ +.section, +.sideCard { + background: var(--surface-panel); + border: 1px solid var(--border-soft); + border-radius: var(--radius-3xl); + box-shadow: var(--shadow-soft); + padding: 1.2rem; + min-width: 0; +} + +.primaryAction, +.secondaryAction, +.queueButton { + font: inherit; +} + +.primaryAction, +.secondaryAction { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.8rem 1rem; + border-radius: var(--radius-pill); + text-decoration: none; + font-weight: 600; + border: 1px solid transparent; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + transform 0.2s ease, + opacity 0.2s ease; +} + +.secondaryAction { + background: var(--surface-soft); + color: var(--text); + border-color: var(--border-soft); +} + +.secondaryAction:hover { + text-decoration: none; + background: color-mix(in srgb, var(--surface-soft) 88%, white 12%); + border-color: var(--border-soft); +} + +.primaryAction { + background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end)); + color: var(--action-primary-text); + box-shadow: var(--action-primary-shadow); +} + +.primaryAction:hover { + filter: saturate(1.02); +} + +.primaryAction:disabled, +.secondaryAction:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.sideEyebrow, +.order, +.feedbackBlock label, +.responseBlock p, +.supportBlock p { + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.78rem; + font-weight: 700; + color: var(--text-subtle); +} + +.statusPill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.42rem 0.68rem; + border-radius: var(--radius-pill); + font-size: 0.78rem; + font-weight: 700; + line-height: 1; + white-space: nowrap; + text-transform: uppercase; + border: 1px solid transparent; +} + +.review { + background: var(--surface-warning-tint); + color: var(--warning-text); + border-color: color-mix(in srgb, var(--warning-text) 24%, transparent 76%); +} + +.progress { + background: color-mix(in srgb, var(--surface-info) 20%, white 80%); + color: var(--info); + border-color: color-mix(in srgb, var(--info) 24%, transparent 76%); +} + +.success { + background: var(--surface-success-tint); + color: var(--success-text); + border-color: var(--border-success-soft); +} + +.muted { + background: var(--surface-soft); + color: var(--text-muted); + border-color: var(--border-soft); +} + +.contentGrid { + display: grid; + gap: 1.25rem; + + @include respond(desktop-lg) { + grid-template-columns: minmax(0, 1.45fr) minmax(19rem, 0.85fr); + align-items: start; + } +} + +@media (max-width: 1023px) { + .contentGrid { + display: flex; + flex-direction: column; + } + + .sideColumn { + order: -1; + } +} + +.mainColumn, +.sideColumn { + min-width: 0; +} + +.sideColumn { + display: grid; + gap: 1rem; + min-height: 0; + + @include respond(desktop-lg) { + position: sticky; + top: 1.25rem; + max-height: calc(100dvh - 2.5rem); + overflow-y: auto; + padding-right: 0.2rem; + } +} + +.queueCard { + min-height: 0; +} + +.mobileStickyNav { + position: static; +} + +.mobileStickyNavHeader { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 0.75rem; +} + +.mobileStickyNavActions { + display: flex; + align-items: center; + gap: 0.55rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.mobileStickyNavToggle { + display: none; + align-items: center; + justify-content: center; + padding: 0.7rem 0.9rem; + border-radius: var(--radius-full); + border: 1px solid var(--border-soft); + background: var(--surface-panel-strong); + color: var(--text); + font: inherit; + font-size: 0.84rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; +} + +.mobileStickyNavBackToTop { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.7rem 0.9rem; + border-radius: var(--radius-full); + border: 1px solid color-mix(in srgb, var(--border-soft) 74%, white 26%); + background: color-mix(in srgb, var(--surface-panel-strong) 82%, white 18%); + color: var(--text); + font: inherit; + font-size: 0.84rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + box-shadow: 0 8px 18px hsl(220 35% 12% / 0.1); + transition: + background-color 0.2s ease, + border-color 0.2s ease, + transform 0.2s ease; + + &:hover { + transform: translateY(-1px); + background: color-mix(in srgb, var(--surface-panel-strong) 74%, white 26%); + } +} + +.mobileStickyNavContent { + display: grid; + gap: 0.85rem; +} + +.mobileStickyNavContentExpanded { + display: grid; + gap: 0.85rem; +} + +.mobileQuestionNavList { + display: grid; + gap: 0.65rem; +} + +.mobileQuestionNavButton { + display: grid; + gap: 0.15rem; + padding: 0.85rem 0.95rem; + text-align: left; + border-radius: var(--radius-md); + border: 1px solid var(--border-divider); + background: var(--surface-panel-strong); + color: var(--text); + cursor: pointer; + transition: + border-color 0.2s ease, + background-color 0.2s ease, + transform 0.2s ease; + + &:hover { + transform: translateY(-1px); + } + + strong { + font-size: 0.95rem; + color: var(--text); + } + + small { + color: var(--text-muted); + } +} + +.mobileQuestionNavAnswered { + border-color: color-mix(in srgb, var(--border-soft) 62%, var(--info) 38%); + background: color-mix(in srgb, var(--surface-soft) 88%, white 12%); +} + +.sectionHeader, +.sideCard { + display: grid; + gap: 0.4rem; +} + +.questionList, +.queueList, +.noteList { + display: grid; + gap: 0.85rem; +} + +.sideActions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.queueScroller { + min-height: 0; +} + +.assignmentFeedbackCard { + display: grid; + gap: 0.85rem; + padding: 1rem; + border-radius: var(--radius-lg); + border: 1px solid var(--border-soft); + background: var(--surface-panel-strong); + margin-bottom: 0.95rem; +} + +.saveAllCard { + display: grid; + gap: 0.85rem; + padding: 1rem; + border-radius: var(--radius-lg); + border: 1px solid var(--border-soft); + background: var(--surface-panel-strong); + margin-top: 1rem; +} + +.nextStepCard, +.optionCard { + display: grid; + gap: 0.85rem; + padding: 1rem; + border-radius: var(--radius-lg); + border: 1px solid var(--border-soft); + background: var(--surface-panel-strong); + min-width: 0; +} + +.planList, +.scoreList { + display: grid; + gap: 0.85rem; +} + +.planCard { + display: grid; + gap: 0.85rem; + padding: 1rem; + border-radius: var(--radius-lg); + border: 1px solid var(--border-soft); + background: var(--surface-panel-strong); + min-width: 0; +} + +.scoreRow { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + padding: 0.8rem 0.95rem; + border-radius: var(--radius-md); + border: 1px solid var(--border-soft); + background: var(--surface-soft); + + span { + color: var(--text); + } + + strong { + color: var(--text-muted); + } +} + +.questionCard, +.queueButton { + display: grid; + gap: 0.85rem; + padding: 1rem; + border-radius: var(--radius-lg); + border: 1px solid var(--border-soft); + background: var(--surface-panel-strong); + min-width: 0; +} + +.optionGrid { + display: grid; + gap: 0.85rem; + + @include respond(tablet) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.optionCard { + text-align: left; + cursor: pointer; + transition: + border-color 0.2s ease, + background-color 0.2s ease, + transform 0.2s ease; + + strong { + font-size: 1rem; + color: var(--text); + } + + span { + color: var(--text-muted); + line-height: 1.5; + } + + &:hover { + border-color: color-mix(in srgb, var(--border-soft) 68%, var(--info) 32%); + background: color-mix(in srgb, var(--surface-panel-strong) 86%, white 14%); + } +} + +.optionCardActive { + border-color: color-mix(in srgb, var(--border-soft) 54%, var(--info) 46%); + background: color-mix(in srgb, var(--surface-info) 14%, white 86%); + box-shadow: var(--focus-ring-info-shadow); +} + +.questionTop { + display: flex; + justify-content: space-between; + align-items: start; + gap: 0.75rem; + + h3 { + line-height: 1.25; + } +} + +.metaRow { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + + span { + padding: 0.35rem 0.55rem; + border-radius: var(--radius-pill); + background: var(--surface-soft); + color: color-mix(in srgb, var(--text-muted) 84%, var(--info) 16%); + font-size: 0.82rem; + border: 1px solid var(--border-soft); + } +} + +.responseBlock, +.supportBlock, +.feedbackBlock { + display: grid; + gap: 0.45rem; +} + +.reviewEditorCard { + display: grid; + gap: 0.9rem; + padding: 0.95rem 1rem; + border-radius: var(--radius-md); + border: 1px solid var(--border-soft); + background: color-mix(in srgb, var(--surface-soft) 88%, white 12%); +} + +.reviewEditorTop { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + + h4 { + font-size: 1rem; + color: var(--text); + } +} + +.reviewCheckbox { + display: inline-flex; + align-items: center; + gap: 0.55rem; + font-weight: 600; + color: var(--text); + + input { + inline-size: 1rem; + block-size: 1rem; + } +} + +.reviewField { + display: grid; + gap: 0.35rem; + + label { + font-size: 0.85rem; + font-weight: 700; + color: var(--text-muted); + } + + small { + color: var(--text-muted); + line-height: 1.4; + } +} + +.reviewScoreGrid { + display: grid; + gap: 0.75rem; + + @include respond(tablet) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.scoreHighlight { + padding: 1rem; + border-radius: var(--radius-lg); + border: 1px solid color-mix(in srgb, var(--border-soft) 56%, var(--info) 44%); + background: color-mix(in srgb, var(--surface-info) 12%, white 88%); + + @include respond(tablet) { + grid-column: 1 / -1; + } + + label { + font-size: 0.8rem; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + small { + font-size: 0.9rem; + } +} + +.scoreHighlightValue { + display: flex; + align-items: baseline; + gap: 0.5rem; + min-height: 3rem; + + strong, + span { + font-size: clamp(2rem, 4vw, 3rem); + line-height: 1; + font-weight: 800; + color: var(--text); + } +} + +.compactInput { + width: 100%; + min-width: 0; + padding: 0.75rem 0.85rem; + border-radius: var(--radius-md); + border: 1px solid var(--border-soft); + background: var(--surface-panel); + color: var(--text); + font: inherit; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + background-color 0.2s ease; + + &:focus { + outline: none; + border-color: color-mix(in srgb, var(--border-soft) 56%, var(--info) 44%); + box-shadow: var(--focus-ring-info-shadow); + background: color-mix(in srgb, var(--surface-panel) 88%, white 12%); + } + } + +.tagList { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.tagPill { + display: inline-flex; + align-items: center; + padding: 0.4rem 0.7rem; + border-radius: 999px; + border: 1px solid var(--border-soft); + background: var(--surface-soft); + color: var(--text-muted); + font-size: 0.9rem; + line-height: 1.2; +} + +.reviewDraftEmpty { + padding: 0.9rem 1rem; + border-radius: var(--radius-md); + border: 1px dashed var(--border-soft); + background: var(--surface-soft); + color: var(--text-muted); + font-size: 0.94rem; + line-height: 1.45; +} + +.responseBlock, +.supportBlock { + padding: 0.9rem 1rem; + border-radius: var(--radius-md); + border: 1px solid var(--border-soft); + background: var(--surface-soft); +} + +.responseBlock strong, +.supportBlock span, +.supportBlock pre { + font-size: 0.98rem; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.responseBlock strong { + color: color-mix(in srgb, var(--text) 88%, var(--info) 12%); +} + +.supportBlock span, +.supportBlock pre { + color: var(--text-muted); + margin: 0; +} + +.feedbackInput { + width: 100%; + min-height: 8rem; + padding: 0.9rem 1rem; + border-radius: var(--radius-md); + border: 1px solid var(--border-soft); + background: var(--surface-soft); + color: var(--text); + font: inherit; + resize: vertical; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + background-color 0.2s ease; +} + +.feedbackInput:focus { + outline: none; + border-color: color-mix(in srgb, var(--border-soft) 56%, var(--info) 44%); + box-shadow: var(--focus-ring-info-shadow); + background: color-mix(in srgb, var(--surface-soft) 88%, white 12%); +} + +.draftHint { + font-size: 0.92rem; + line-height: 1.45; + color: var(--text-muted); +} + +.actionRow { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.notice { + padding: 0.8rem 0.95rem; + border-radius: var(--radius-md); + font-size: 0.92rem; + font-weight: 600; +} + +.noticeSuccess { + background: var(--surface-success-tint); + color: var(--success-text); +} + +.noticeError { + background: var(--surface-danger); + color: var(--danger-text); +} + +.queueButton { + text-align: left; + cursor: pointer; + color: var(--text); + transition: + border-color 0.2s ease, + background-color 0.2s ease; + + strong { + color: var(--text); + } + + p { + color: var(--text-muted); + } + + &:hover { + border-color: color-mix(in srgb, var(--border-soft) 70%, var(--info) 30%); + background: color-mix(in srgb, var(--surface-soft) 92%, white 8%); + } +} + +.queueButtonActive { + border-color: color-mix(in srgb, var(--border-soft) 60%, var(--info) 40%); + background: color-mix(in srgb, var(--surface-soft) 86%, white 14%); +} + +.queueMeta { + display: grid; + gap: 0.35rem; + justify-items: start; + + small { + color: var(--text-muted); + } +} + +.queueEmpty, +.emptyState, +.progressNote { + padding: 0.95rem; + border-radius: var(--radius-md); + background: var(--surface-soft); + color: var(--text-muted); + border: 1px dashed var(--border-soft); +} + +.closeAssignmentStatusRow { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; +} + +.closeBlockerList { + display: grid; + gap: 0.55rem; + padding-left: 1rem; + + li { + color: var(--text-muted); + line-height: 1.45; + } +} + +.noteList { + padding-left: 1rem; + + li { + color: var(--text-muted); + line-height: 1.45; + } +} + +@media (max-width: 1023px) { + .mobileStickyNav { + position: sticky; + top: 0.75rem; + z-index: 25; + pointer-events: auto; + background: color-mix(in srgb, var(--surface-panel) 84%, white 16%); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid color-mix(in srgb, var(--border-soft) 78%, white 22%); + border-radius: calc(var(--radius-3xl) + 0.25rem); + box-shadow: 0 16px 36px hsl(220 35% 12% / 0.14); + } + + .mobileStickyNav { + gap: 0.75rem; + padding: 0.95rem; + } + + .mobileStickyNavHeader { + grid-template-columns: minmax(0, 1fr); + } + + .mobileStickyNavActions { + justify-content: space-between; + } + + .mobileStickyNavToggle { + display: inline-flex; + } + + .mobileStickyNavContent { + display: none; + } + + .mobileStickyNavContentExpanded { + display: grid; + } + + .queueScroller, + .mobileQuestionNavList { + max-height: min(55dvh, 24rem); + overflow-y: auto; + padding-right: 0.15rem; + } + + .section, + .sideCard, + .questionCard, + .queueButton, + .assignmentFeedbackCard, + .saveAllCard { + padding: 1rem; + } + + .questionTop, + .reviewEditorTop { + display: grid; + gap: 0.6rem; + } + + .questionCard { + scroll-margin-top: 8.5rem; + } + + .sideActions { + gap: 0.55rem; + } + + .sideActions > * { + flex: 1 1 auto; + } + + .queueMeta { + width: 100%; + grid-template-columns: minmax(0, 1fr); + } +} + +@include respond(desktop-lg) { + .mobileStickyNavToggle { + display: none; + } + + .mobileStickyNavContent { + display: grid; + } +} diff --git a/Frontend/src/components/assignment/teacher/assignment-teacher-review.sections.tsx b/Frontend/src/components/assignment/teacher/assignment-teacher-review.sections.tsx new file mode 100644 index 0000000..d270142 --- /dev/null +++ b/Frontend/src/components/assignment/teacher/assignment-teacher-review.sections.tsx @@ -0,0 +1,388 @@ +// Path: Frontend/src/components/assignment/teacher/assignment-teacher-review.sections.tsx + +import type { Component, JSX } from "solid-js"; +import { A } from "@solidjs/router"; +import { createSignal, For, Show } from "solid-js"; +import { assignmentUiCopy } from "~/content/ui-copy"; +import type { TeacherAssignmentReviewPageData, TeacherReviewDraftFields, TeacherReviewNotice, TeacherReviewQuestion } from "./assignment-teacher-review.types"; +import { getDashboardAssignmentsHref } from "../../../lib/routes"; +import styles from "./assignment-teacher-review.module.scss"; + +const toneClass = (tone: string) => { + switch (tone) { + case "review": + return styles.review; + case "progress": + return styles.progress; + case "success": + return styles.success; + default: + return styles.muted; + } +}; + +type AssignmentFeedbackSectionProps = { + data: TeacherAssignmentReviewPageData; + teacherFeedbackDraft: string; + hasPendingAssignmentFeedback: boolean; + busy: boolean; + notice: TeacherReviewNotice; + draftActionLabel: string; + actions?: JSX.Element; + onTeacherFeedbackInput: (value: string) => void; +}; + +export const AssignmentFeedbackSection: Component = (props) => ( +
+
+

{assignmentUiCopy.teacherReview.feedback.title}

+

Keep AI feedback and teacher feedback in one place for the whole assignment.

+
+ +
+

{assignmentUiCopy.teacherReview.feedback.aiFeedback}

+ {props.data.assignmentAiFeedback || assignmentUiCopy.teacherReview.feedback.noAiFeedback} +
+ +
+ +