diff --git a/.example.env b/.example.env index c94d142a..43148b57 100644 --- a/.example.env +++ b/.example.env @@ -44,7 +44,7 @@ NEXT_PUBLIC_APP_NAME=GhostClass # âš ī¸ App version displayed in footer and health checks # 🔨 Build-time (Infisical `/build-time` folder) -NEXT_PUBLIC_APP_VERSION=4.3.8 +NEXT_PUBLIC_APP_VERSION=4.3.9 # âš ī¸ Your production domain WITHOUT https:// # All URL-based variables are derived from this. @@ -359,7 +359,7 @@ JWE_PRIVATE_KEY= # âš ī¸ Minimum supported app version required to bypass forced update # 🚀 Runtime (Infisical `/runtime` folder → Server Env Var) -MIN_APP_VERSION=4.3.8 +MIN_APP_VERSION=4.3.9 # â„šī¸ Enforce Firebase App Check for all mobile clients in production # Valid: "true", "false" (default: false in dev, true recommended in prod) diff --git a/mobile/lib/config/app_config.dart b/mobile/lib/config/app_config.dart index a67c1547..cd19d7df 100644 --- a/mobile/lib/config/app_config.dart +++ b/mobile/lib/config/app_config.dart @@ -75,7 +75,7 @@ class AppConfig { /// Current application version (derived from Infisical compilation injection). static String get appVersion => - const String.fromEnvironment('APP_VERSION', defaultValue: '4.3.8'); + const String.fromEnvironment('APP_VERSION', defaultValue: '4.3.9'); /// Commit SHA injected by CI for release builds. static String get appCommitSha => diff --git a/mobile/lib/providers/dashboard_provider.dart b/mobile/lib/providers/dashboard_provider.dart index 51fd7041..1d0e0b52 100644 --- a/mobile/lib/providers/dashboard_provider.dart +++ b/mobile/lib/providers/dashboard_provider.dart @@ -239,8 +239,6 @@ class DashboardNotifier extends AsyncNotifier { .from('class_courses') .select() .eq('class_id', classId) - .eq('academic_year', academic.year) - .eq('semester', academic.semester) .then((coursesRes) { if (coursesRes.isNotEmpty) { sharedCourses = (coursesRes as List).map((raw) { @@ -261,8 +259,6 @@ class DashboardNotifier extends AsyncNotifier { .from('course_instructors') .select() .eq('class_id', classId) - .eq('semester', academic.semester) - .eq('academic_year', academic.year) .then((instructorsRes) { if (instructorsRes.isNotEmpty) { sharedInstructors = (instructorsRes as List) diff --git a/mobile/lib/screens/dashboard_screen.dart b/mobile/lib/screens/dashboard_screen.dart index ce91f752..0ee8427f 100644 --- a/mobile/lib/screens/dashboard_screen.dart +++ b/mobile/lib/screens/dashboard_screen.dart @@ -24,6 +24,7 @@ class DashboardScreen extends ConsumerStatefulWidget { class _DashboardScreenState extends ConsumerState { bool _isDialogOpen = false; + bool _hasSeenSyncing = false; void _checkAndShowClassDialog() { WidgetsBinding.instance.addPostFrameCallback((_) async { @@ -56,6 +57,10 @@ class _DashboardScreenState extends ConsumerState { final user = ref.watch(authProvider).value; final isSyncing = user?.isSyncing ?? false; + if (isSyncing) { + _hasSeenSyncing = true; + } + if (dashboardState.isLoading || isSyncing) { return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, @@ -85,7 +90,7 @@ class _DashboardScreenState extends ConsumerState { ); } - if (user != null && !isSyncing) { + if (user != null && !isSyncing && _hasSeenSyncing) { _checkAndShowClassDialog(); } diff --git a/mobile/lib/services/api_service.dart b/mobile/lib/services/api_service.dart index 73f6774a..2d34c544 100644 --- a/mobile/lib/services/api_service.dart +++ b/mobile/lib/services/api_service.dart @@ -210,8 +210,6 @@ class ApiService { Future> addCourse({ required String courseCode, required String courseName, - required String semester, - required String academicYear, required String supabaseToken, }) async { return client.post( @@ -219,8 +217,6 @@ class ApiService { data: { 'courseCode': courseCode, 'courseName': courseName, - 'semester': semester, - 'academicYear': academicYear, }, options: Options(headers: {'Authorization': 'Bearer $supabaseToken'}), ); @@ -229,8 +225,6 @@ class ApiService { Future> upsertInstructor({ required String courseCode, required String instructorName, - required String semester, - required String academicYear, required String supabaseToken, }) async { return client.post( @@ -238,8 +232,6 @@ class ApiService { data: { 'courseCode': courseCode, 'instructorName': instructorName, - 'semester': semester, - 'academicYear': academicYear, }, options: Options(headers: {'Authorization': 'Bearer $supabaseToken'}), ); diff --git a/mobile/lib/widgets/attendance/add_course_dialog.dart b/mobile/lib/widgets/attendance/add_course_dialog.dart index 7b51bf76..da3b116e 100644 --- a/mobile/lib/widgets/attendance/add_course_dialog.dart +++ b/mobile/lib/widgets/attendance/add_course_dialog.dart @@ -52,8 +52,6 @@ class _AddCourseDialogState extends ConsumerState { final res = await api.addCourse( courseCode: _codeController.text.trim().toUpperCase(), courseName: _nameController.text.trim().replaceAll(RegExp(r'\s+'), ' '), - semester: widget.semester, - academicYear: widget.academicYear, supabaseToken: supabaseToken, ); diff --git a/mobile/lib/widgets/attendance/edit_instructor_dialog.dart b/mobile/lib/widgets/attendance/edit_instructor_dialog.dart index 92c5cc00..35d0bc04 100644 --- a/mobile/lib/widgets/attendance/edit_instructor_dialog.dart +++ b/mobile/lib/widgets/attendance/edit_instructor_dialog.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ghostclass/logic/attendance_utils.dart' as utils; import 'package:ghostclass/logic/error_utils.dart'; -import 'package:ghostclass/providers/academic_provider.dart'; import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/providers/dashboard_provider.dart'; import 'package:ghostclass/services/api_service.dart'; @@ -55,9 +54,8 @@ class _EditInstructorDialogState extends ConsumerState { setState(() => _isSaving = true); try { - final academic = ref.read(academicProvider).value; final auth = ref.read(authProvider).value; - if (academic == null || auth == null) throw Exception('Missing context'); + if (auth == null) throw Exception('Missing context'); final apiService = ref.read(apiServiceProvider); final client = ref.read(supabaseClientProvider); @@ -67,8 +65,6 @@ class _EditInstructorDialogState extends ConsumerState { await apiService.upsertInstructor( courseCode: widget.courseCode, instructorName: name, - semester: academic.semester, - academicYear: academic.year, supabaseToken: supabaseToken, ); diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index eb8bab99..a921da2d 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 4.3.8+1 +version: 4.3.9+1 environment: sdk: ^3.11.4 diff --git a/mobile/test/widgets/attendance/edit_instructor_dialog_test.dart b/mobile/test/widgets/attendance/edit_instructor_dialog_test.dart index 6bebfbab..10e4eb85 100644 --- a/mobile/test/widgets/attendance/edit_instructor_dialog_test.dart +++ b/mobile/test/widgets/attendance/edit_instructor_dialog_test.dart @@ -79,8 +79,6 @@ void main() { () => mockApi.upsertInstructor( courseCode: any(named: 'courseCode'), instructorName: any(named: 'instructorName'), - semester: any(named: 'semester'), - academicYear: any(named: 'academicYear'), supabaseToken: any(named: 'supabaseToken'), ), ).thenAnswer( @@ -159,8 +157,6 @@ void main() { () => mockApi.upsertInstructor( courseCode: 'CS101', instructorName: 'Dr. New', - semester: 'odd', - academicYear: '2024-2025', supabaseToken: 'test-supabase-token', ), ).called(1); diff --git a/package-lock.json b/package-lock.json index 063aa134..f6b96f55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostclass", - "version": "4.3.8", + "version": "4.3.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostclass", - "version": "4.3.8", + "version": "4.3.9", "dependencies": { "@hookform/resolvers": "^5.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", diff --git a/package.json b/package.json index d692322e..8f69d556 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostclass", - "version": "4.3.8", + "version": "4.3.9", "private": true, "engines": { "node": ">=22.12.0", diff --git a/public/openapi/openapi.yaml b/public/openapi/openapi.yaml index 33951c20..750fb6b4 100644 --- a/public/openapi/openapi.yaml +++ b/public/openapi/openapi.yaml @@ -6,7 +6,7 @@ openapi: 3.1.0 info: title: GhostClass API - version: 4.3.8 + version: 4.3.9 description: | **GhostClass API** provides endpoints for authentication, profile synchronization, attendance integrations with EzyGo, telemetry, and build provenance. @@ -749,7 +749,6 @@ paths: description: Too many requests content: application/json: - schema: $ref: "#/components/schemas/RateLimitError" /api/courses/add: @@ -773,8 +772,6 @@ paths: required: - courseCode - courseName - - semester - - academicYear properties: courseCode: type: string @@ -784,14 +781,6 @@ paths: type: string description: The name of the course example: "Introduction to Computer Science" - semester: - type: string - description: The semester name - example: "Fall 2026" - academicYear: - type: string - description: The academic year - example: "2026" responses: "201": description: Course added successfully @@ -833,7 +822,7 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - + /api/instructors/upsert: post: tags: @@ -854,8 +843,6 @@ paths: required: - courseCode - instructorName - - semester - - academicYear properties: courseCode: type: string @@ -863,12 +850,6 @@ paths: instructorName: type: string example: "Dr. John Doe" - semester: - type: string - example: "Fall 2026" - academicYear: - type: string - example: "2026" responses: "200": description: Instructor saved successfully diff --git a/src/app/(protected)/dashboard/DashboardClient.tsx b/src/app/(protected)/dashboard/DashboardClient.tsx index c2667aac..9aeffd1c 100644 --- a/src/app/(protected)/dashboard/DashboardClient.tsx +++ b/src/app/(protected)/dashboard/DashboardClient.tsx @@ -215,11 +215,11 @@ const getSortPriority = (item: { isDisabled?: boolean; isNew?: boolean }) => { }; export default function DashboardClient({ initialData, serverError }: DashboardClientProps) { - const { data: rawProfile, isLoading: isLoadingProfile, refetch: refetchProfile } = useProfile({ sync: true, force: true }); + const { data: rawProfile, isLoading: isLoadingProfile } = useProfile({ sync: true, force: true }); const profile = rawProfile as UserProfile | undefined; const queryClient = useQueryClient(); - const setSemesterMutation = useSetSemester(); - const setAcademicYearMutation = useSetAcademicYear(); + const setSemesterMutation = useSetSemester({ skipInvalidations: true }); + const setAcademicYearMutation = useSetAcademicYear({ skipInvalidations: true }); const { targetPercentage } = useAttendanceSettings(); const { data: userSettings, isLoading: isSettingsLoading } = useFetchUserSettings(); @@ -273,6 +273,13 @@ export default function DashboardClient({ initialData, serverError }: DashboardC return () => clearTimeout(timer); }, [syncCompleted, profile]); + const isInitialDataValid = useMemo(() => { + if (selectedSemester !== null || selectedYear !== null) return false; + if (ezygoSemester && ezygoSemester !== effectiveSemester) return false; + if (ezygoYear && ezygoYear !== effectiveYear) return false; + return true; + }, [selectedSemester, selectedYear, ezygoSemester, ezygoYear, effectiveSemester, effectiveYear]); + const isAttendanceStale = initialData?.attendance && typeof initialData.attendance === "object" && @@ -284,15 +291,38 @@ export default function DashboardClient({ initialData, serverError }: DashboardC const { data: rawAttendanceData, isLoading: isLoadingAttendance, refetch: refetchAttendance } = useAttendanceReport(currentSem, currentYear, { enabled: syncCompleted, - initialData: isAttendanceStale ? undefined : (initialData?.attendance as AttendanceReport ?? undefined), + initialData: (isInitialDataValid && !isAttendanceStale) ? (initialData?.attendance as AttendanceReport ?? undefined) : undefined, }); const attendanceData = rawAttendanceData as AttendanceReport | undefined; + const formattedInitialCourses = useMemo(() => { + if (!initialData?.courses) return undefined; + if (Array.isArray(initialData.courses)) { + return { + courses: initialData.courses.reduce( + (acc: Record, course: Course) => { + acc[course.id.toString()] = course; + return acc; + }, + {} + ), + }; + } + if ( + typeof initialData.courses === "object" && + initialData.courses !== null && + "courses" in initialData.courses + ) { + return initialData.courses as { courses: Record }; + } + return undefined; + }, [initialData]); + const { data: rawCoursesData, isLoading: isLoadingCourses } = useFetchCourses({ semester: currentSem, year: currentYear, enabled: syncCompleted && !!currentSem && !!currentYear, - initialData: initialData?.courses as { courses: Record } ?? undefined, + initialData: isInitialDataValid ? formattedInitialCourses : undefined, }); const coursesData = rawCoursesData as { courses: Record } | undefined; @@ -332,7 +362,7 @@ export default function DashboardClient({ initialData, serverError }: DashboardC return Array.from(registry.values()); }, [coursesData, classCourses]); - const { data: allCourseSummaries, isLoading: isLoadingAllCourseSummaries } = useAllCourseDetails(syncCompleted ? courseList : []); + const { data: allCourseSummaries, isLoading: isLoadingAllCourseSummaries } = useAllCourseDetails(courseList); const { disabledCodes } = useDisabledCourses({ academicYear: currentYear, semester: currentSem }); @@ -358,18 +388,43 @@ export default function DashboardClient({ initialData, serverError }: DashboardC setShowConfirmDialog(false); try { + const mutations = []; if (pendingChange.year !== effectiveYear) { - await setAcademicYearMutation.mutateAsync({ default_academic_year: pendingChange.year }); + mutations.push( + setAcademicYearMutation.mutateAsync({ + default_academic_year: pendingChange.year, + }) + ); } - if (pendingChange.semester !== effectiveSemester) { - await setSemesterMutation.mutateAsync({ default_semester: pendingChange.semester }); + mutations.push( + setSemesterMutation.mutateAsync({ + default_semester: pendingChange.semester, + }) + ); } + await Promise.all(mutations); + setSelectedSemester(pendingChange.semester); setSelectedYear(pendingChange.year); - queryClient.invalidateQueries({ queryKey: ["profile"] }); - await refetchProfile(); + + // Coordinated, single invalidation for all queries affected by the semester/year shift. + // This includes resetting the profile query which triggers the background sync once. + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["courses"] }), + queryClient.invalidateQueries({ queryKey: ["attendance-report"] }), + queryClient.invalidateQueries({ queryKey: ["attendance-report-all"] }), + queryClient.invalidateQueries({ queryKey: ["class_courses"] }), + queryClient.invalidateQueries({ queryKey: ["course_instructors"] }), + queryClient.invalidateQueries({ queryKey: ["track_data"] }), + queryClient.invalidateQueries({ queryKey: ["count"] }), + queryClient.invalidateQueries({ queryKey: ["profile"] }), + queryClient.invalidateQueries({ queryKey: ["exams"] }), + queryClient.invalidateQueries({ queryKey: ["exam-answers"] }), + queryClient.invalidateQueries({ queryKey: ["exam-questions"] }), + queryClient.invalidateQueries({ queryKey: ["exam-details-batch"] }), + ]); } catch (error) { logger.error("Update Failed:", error); toast.error("Failed to update settings"); @@ -572,7 +627,7 @@ export default function DashboardClient({ initialData, serverError }: DashboardC Promise.all([refetchAttendance(), refetchTracking()])} selectedSemester={currentSem} selectedYear={currentYear} /> - + {currentSem && currentYear && ( { const formData = new FormData(); formData.append("courseCode", ""); const result = await addCourseAction(formData); - expect(result.error).toBe("Course code, name, semester, and academic year are required"); + expect(result.error).toBe("Course code and name are required"); }); it("returns error if turnstile token is missing", async () => { const formData = new FormData(); formData.append("courseCode", "CS101"); formData.append("courseName", "Intro CS"); - formData.append("semester", "odd"); - formData.append("academicYear", "2024-2025"); const result = await addCourseAction(formData); expect(result.error).toContain("Security verification failed"); }); @@ -52,8 +50,6 @@ describe("course actions", () => { const formData = new FormData(); formData.append("courseCode", "CS101"); formData.append("courseName", "Intro CS"); - formData.append("semester", "odd"); - formData.append("academicYear", "2024-2025"); formData.append("cf-turnstile-response", "invalid"); vi.mocked(fetch).mockResolvedValue({ @@ -68,8 +64,6 @@ describe("course actions", () => { const formData = new FormData(); formData.append("courseCode", "CS 101"); formData.append("courseName", "intro to computer science"); - formData.append("semester", "odd"); - formData.append("academicYear", "2024-2025"); formData.append("cf-turnstile-response", "valid"); vi.mocked(fetch).mockResolvedValue({ @@ -102,8 +96,6 @@ describe("course actions", () => { const formData = new FormData(); formData.append("courseCode", "CS101"); formData.append("courseName", "Intro CS"); - formData.append("semester", "odd"); - formData.append("academicYear", "2024-2025"); formData.append("cf-turnstile-response", "valid"); vi.mocked(fetch).mockResolvedValue({ json: async () => ({ success: true }) } as never); diff --git a/src/app/actions/__tests__/instructors.test.ts b/src/app/actions/__tests__/instructors.test.ts index 1dc0f500..91fa6d42 100644 --- a/src/app/actions/__tests__/instructors.test.ts +++ b/src/app/actions/__tests__/instructors.test.ts @@ -35,15 +35,13 @@ describe("instructor actions", () => { const formData = new FormData(); formData.append("courseCode", ""); const result = await upsertInstructorAction(formData); - expect(result.error).toBe("Course code, instructor name, semester, and academic year are required"); + expect(result.error).toBe("Course code and instructor name are required"); }); it("successfully upserts an instructor", async () => { const formData = new FormData(); formData.append("courseCode", "CS 101"); formData.append("instructorName", "john doe"); - formData.append("semester", "odd"); - formData.append("academicYear", "2024-2025"); formData.append("cf-turnstile-response", "valid"); vi.mocked(fetch).mockResolvedValue({ @@ -76,8 +74,6 @@ describe("instructor actions", () => { const formData = new FormData(); formData.append("courseCode", "CS101"); formData.append("instructorName", "John"); - formData.append("semester", "odd"); - formData.append("academicYear", "2024-2025"); formData.append("cf-turnstile-response", "valid"); vi.mocked(fetch).mockResolvedValue({ json: async () => ({ success: true }) } as never); diff --git a/src/app/actions/courses.ts b/src/app/actions/courses.ts index df858e2b..8578eebe 100644 --- a/src/app/actions/courses.ts +++ b/src/app/actions/courses.ts @@ -5,41 +5,33 @@ import { createClient } from "@/lib/supabase/server"; import { revalidatePath } from "next/cache"; import * as Sentry from "@sentry/nextjs"; import { z } from "zod"; -import { academicYearSchema, courseCodeSchema, courseNameSchema, semesterSchema } from "@/lib/validation/text"; +import { courseCodeSchema, courseNameSchema } from "@/lib/validation/text"; export async function addCourseAction(formData: FormData): Promise<{ error?: string }> { const courseCodeValue = formData.get("courseCode"); const courseNameValue = formData.get("courseName"); - const semesterValue = formData.get("semester"); - const academicYearValue = formData.get("academicYear"); if ( typeof courseCodeValue !== "string" || courseCodeValue.trim() === "" || - typeof courseNameValue !== "string" || courseNameValue.trim() === "" || - typeof semesterValue !== "string" || semesterValue.trim() === "" || - typeof academicYearValue !== "string" || academicYearValue.trim() === "" + typeof courseNameValue !== "string" || courseNameValue.trim() === "" ) { - return { error: "Course code, name, semester, and academic year are required" }; + return { error: "Course code and name are required" }; } // Strict sanitization: Trim all inputs, capitalize and strip spaces from code, title case the name. const parsed = z.object({ courseCode: courseCodeSchema, courseName: courseNameSchema, - semester: semesterSchema, - academicYear: academicYearSchema, }).safeParse({ courseCode: courseCodeValue, courseName: courseNameValue, - semester: semesterValue, - academicYear: academicYearValue, }); if (!parsed.success) { return { error: parsed.error.issues[0]?.message ?? "Invalid course details" }; } - const { courseCode: code, courseName: name, semester, academicYear } = parsed.data; + const { courseCode: code, courseName: name } = parsed.data; const turnstileToken = String(formData.get("cf-turnstile-response") ?? ""); // 1. Verify Turnstile Security Token @@ -96,8 +88,6 @@ export async function addCourseAction(formData: FormData): Promise<{ error?: str class_id: profile.class_id, course_code: code.toUpperCase().replace(/[\s\u00A0-]/g, ""), course_name: name, - semester, - academic_year: academicYear, created_by: user.id }); diff --git a/src/app/actions/instructors.ts b/src/app/actions/instructors.ts index bb087be9..3e5ee568 100644 --- a/src/app/actions/instructors.ts +++ b/src/app/actions/instructors.ts @@ -5,43 +5,35 @@ import { createClient } from "@/lib/supabase/server"; import { revalidatePath } from "next/cache"; import * as Sentry from "@sentry/nextjs"; import { z } from "zod"; -import { academicYearSchema, courseCodeSchema, personNameSchema, semesterSchema } from "@/lib/validation/text"; +import { courseCodeSchema, personNameSchema } from "@/lib/validation/text"; export async function upsertInstructorAction( formData: FormData, ): Promise<{ error?: string }> { const courseCodeValue = formData.get("courseCode"); const instructorNameValue = formData.get("instructorName"); - const semesterValue = formData.get("semester"); - const academicYearValue = formData.get("academicYear"); if ( typeof courseCodeValue !== "string" || courseCodeValue.trim() === "" || - typeof instructorNameValue !== "string" || instructorNameValue.trim() === "" || - typeof semesterValue !== "string" || semesterValue.trim() === "" || - typeof academicYearValue !== "string" || academicYearValue.trim() === "" + typeof instructorNameValue !== "string" || instructorNameValue.trim() === "" ) { - return { error: "Course code, instructor name, semester, and academic year are required" }; + return { error: "Course code and instructor name are required" }; } // Strict sanitization: Trim all inputs, capitalize and strip spaces from code, title case the name. const parsed = z.object({ courseCode: courseCodeSchema, instructorName: personNameSchema, - semester: semesterSchema, - academicYear: academicYearSchema, }).safeParse({ courseCode: courseCodeValue, instructorName: instructorNameValue, - semester: semesterValue, - academicYear: academicYearValue, }); if (!parsed.success) { return { error: parsed.error.issues[0]?.message ?? "Invalid instructor details" }; } - const { courseCode, instructorName, semester, academicYear } = parsed.data; + const { courseCode, instructorName } = parsed.data; const turnstileToken = String(formData.get("cf-turnstile-response") ?? ""); // 1. Verify Turnstile Security Token @@ -98,11 +90,9 @@ export async function upsertInstructorAction( class_id: profile.class_id, course_code: courseCode.toUpperCase().replace(/[\s\u00A0-]/g, ""), instructor_name: instructorName, - semester, - academic_year: academicYear, updated_by: user.id }, { - onConflict: "class_id, course_code, semester, academic_year" + onConflict: "class_id, course_code" }); if (upsertError) { diff --git a/src/app/api/attendance/summary-batch/route.ts b/src/app/api/attendance/summary-batch/route.ts index f1b7ddee..09309cfa 100644 --- a/src/app/api/attendance/summary-batch/route.ts +++ b/src/app/api/attendance/summary-batch/route.ts @@ -85,9 +85,12 @@ const handler = async (req: NextRequest, { decryptedBody }: { decryptedBody?: Ba const AttendanceSummarySchema = z.object({ present: z.number(), absent: z.number(), - total: z.number(), - percentage: z.number(), - course: z.object({ id: z.number(), name: z.string(), code: z.string() }), + total: z.number().optional(), + totel: z.number().optional(), + percentage: z.number().optional(), + persantage: z.number().optional(), + persentage: z.number().optional(), + course: z.object({ id: z.number(), name: z.string(), code: z.string() }).optional(), error: z.string().optional() }); diff --git a/src/app/api/courses/add/__tests__/route.test.ts b/src/app/api/courses/add/__tests__/route.test.ts index 43dbf17a..f73fe054 100644 --- a/src/app/api/courses/add/__tests__/route.test.ts +++ b/src/app/api/courses/add/__tests__/route.test.ts @@ -26,8 +26,6 @@ vi.mock("@/lib/logger", () => ({ const MOCK_COURSE = { courseCode: "CS101", courseName: "Intro to Computer Science", - semester: "odd", - academicYear: "2024-2025", }; describe("POST /api/courses/add", () => { @@ -109,8 +107,6 @@ describe("POST /api/courses/add", () => { class_id: "class-456", course_code: "CS101", course_name: "Intro to Computer Science", - semester: "odd", - academic_year: "2024-2025", created_by: "user-123" }); }); diff --git a/src/app/api/courses/add/route.ts b/src/app/api/courses/add/route.ts index a457276d..6ac29a89 100644 --- a/src/app/api/courses/add/route.ts +++ b/src/app/api/courses/add/route.ts @@ -4,7 +4,7 @@ import { createClient } from "@/lib/supabase/server"; import { getAdminClient } from "@/lib/supabase/admin"; import { logger } from "@/lib/logger"; import { z } from "zod"; -import { academicYearSchema, courseCodeSchema, courseNameSchema, semesterSchema } from "@/lib/validation/text"; +import { courseCodeSchema, courseNameSchema } from "@/lib/validation/text"; async function authenticateRequest(req: Request) { const authHeader = req.headers.get("authorization"); @@ -40,14 +40,10 @@ async function handler(req: Request, { decryptedBody }: { decryptedBody?: unknow const rawBody = typeof body === "object" && body !== null ? body as Record : {}; const courseCodeValue = rawBody.courseCode; const courseNameValue = rawBody.courseName; - const semesterValue = rawBody.semester; - const academicYearValue = rawBody.academicYear; if ( typeof courseCodeValue !== "string" || courseCodeValue.trim() === "" || - typeof courseNameValue !== "string" || courseNameValue.trim() === "" || - typeof semesterValue !== "string" || semesterValue.trim() === "" || - typeof academicYearValue !== "string" || academicYearValue.trim() === "" + typeof courseNameValue !== "string" || courseNameValue.trim() === "" ) { return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); } @@ -55,15 +51,13 @@ async function handler(req: Request, { decryptedBody }: { decryptedBody?: unknow const parsed = z.object({ courseCode: courseCodeSchema, courseName: courseNameSchema, - semester: semesterSchema, - academicYear: academicYearSchema, }).safeParse(body); if (!parsed.success) { return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid course details" }, { status: 422 }); } - const { courseCode: code, courseName: name, semester, academicYear } = parsed.data; + const { courseCode: code, courseName: name } = parsed.data; const auth = await authenticateRequest(req); if (!auth) { @@ -91,8 +85,6 @@ async function handler(req: Request, { decryptedBody }: { decryptedBody?: unknow class_id: profile.class_id, course_code: code, course_name: name, - semester, - academic_year: academicYear, created_by: user.id }); diff --git a/src/app/api/instructors/upsert/__tests__/route.test.ts b/src/app/api/instructors/upsert/__tests__/route.test.ts index 8129d3a2..a0ded056 100644 --- a/src/app/api/instructors/upsert/__tests__/route.test.ts +++ b/src/app/api/instructors/upsert/__tests__/route.test.ts @@ -25,8 +25,6 @@ vi.mock("@/lib/logger", () => ({ const MOCK_INSTRUCTOR = { courseCode: "CS101", instructorName: "Dr. Jane Smith", - semester: "odd", - academicYear: "2024-2025", }; describe("POST /api/instructors/upsert", () => { @@ -108,11 +106,9 @@ describe("POST /api/instructors/upsert", () => { class_id: "class-456", course_code: "CS101", instructor_name: "Dr. Jane Smith", - semester: "odd", - academic_year: "2024-2025", updated_by: "user-123" }, { - onConflict: "class_id, course_code, semester, academic_year" + onConflict: "class_id, course_code" }); }); diff --git a/src/app/api/instructors/upsert/route.ts b/src/app/api/instructors/upsert/route.ts index aef2916c..1b77bd3f 100644 --- a/src/app/api/instructors/upsert/route.ts +++ b/src/app/api/instructors/upsert/route.ts @@ -4,7 +4,7 @@ import { createClient } from "@/lib/supabase/server"; import { getAdminClient } from "@/lib/supabase/admin"; import { logger } from "@/lib/logger"; import { z } from "zod"; -import { academicYearSchema, courseCodeSchema, personNameSchema, semesterSchema } from "@/lib/validation/text"; +import { courseCodeSchema, personNameSchema } from "@/lib/validation/text"; async function authenticateRequest(req: Request) { const authHeader = req.headers.get("authorization"); @@ -35,14 +35,10 @@ async function handler(req: NextRequest, { decryptedBody }: { decryptedBody?: un const rawBody = typeof body === "object" && body !== null ? body as Record : {}; const courseCodeValue = rawBody.courseCode; const instructorNameValue = rawBody.instructorName; - const semesterValue = rawBody.semester; - const academicYearValue = rawBody.academicYear; if ( typeof courseCodeValue !== "string" || courseCodeValue.trim() === "" || - typeof instructorNameValue !== "string" || instructorNameValue.trim() === "" || - typeof semesterValue !== "string" || semesterValue.trim() === "" || - typeof academicYearValue !== "string" || academicYearValue.trim() === "" + typeof instructorNameValue !== "string" || instructorNameValue.trim() === "" ) { return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); } @@ -50,15 +46,13 @@ async function handler(req: NextRequest, { decryptedBody }: { decryptedBody?: un const parsed = z.object({ courseCode: courseCodeSchema, instructorName: personNameSchema, - semester: semesterSchema, - academicYear: academicYearSchema, }).safeParse(body); if (!parsed.success) { return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid instructor details" }, { status: 422 }); } - const { courseCode, instructorName, semester, academicYear } = parsed.data; + const { courseCode, instructorName } = parsed.data; const auth = await authenticateRequest(req); if (!auth) { @@ -86,11 +80,9 @@ async function handler(req: NextRequest, { decryptedBody }: { decryptedBody?: un class_id: profile.class_id, course_code: courseCode, instructor_name: instructorName, - semester, - academic_year: academicYear, updated_by: user.id }, { - onConflict: "class_id, course_code, semester, academic_year" + onConflict: "class_id, course_code" }); if (upsertError) { diff --git a/src/components/attendance/AddCourseDialog.tsx b/src/components/attendance/AddCourseDialog.tsx index 4d379345..6647ea39 100644 --- a/src/components/attendance/AddCourseDialog.tsx +++ b/src/components/attendance/AddCourseDialog.tsx @@ -86,8 +86,6 @@ export function AddCourseDialog({ const formData = new FormData(); formData.append("courseCode", courseCode); formData.append("courseName", courseName); - formData.append("semester", semester || profile?.current_semester || ""); - formData.append("academicYear", academicYear || profile?.current_year || ""); formData.append("cf-turnstile-response", token); const csrfToken = getCsrfToken(); diff --git a/src/components/attendance/EditInstructorDialog.tsx b/src/components/attendance/EditInstructorDialog.tsx index 4b774f69..f03c744f 100644 --- a/src/components/attendance/EditInstructorDialog.tsx +++ b/src/components/attendance/EditInstructorDialog.tsx @@ -28,8 +28,6 @@ interface EditInstructorDialogProps { courseCode: string; courseName: string; initialName?: string; - semester: string; - academicYear: string; } export function EditInstructorDialog({ @@ -38,8 +36,6 @@ export function EditInstructorDialog({ courseCode, courseName, initialName = "", - semester, - academicYear, }: EditInstructorDialogProps) { const [name, setName] = useState(initialName); const [isSubmitting, setIsSubmitting] = useState(false); @@ -91,8 +87,6 @@ export function EditInstructorDialog({ const formData = new FormData(); formData.append("courseCode", courseCode); formData.append("instructorName", name); - formData.append("semester", semester); - formData.append("academicYear", academicYear); formData.append("cf-turnstile-response", token); const csrfToken = getCsrfToken(); diff --git a/src/components/attendance/__tests__/EditInstructorDialog.test.tsx b/src/components/attendance/__tests__/EditInstructorDialog.test.tsx index a0add14b..5c2e99b2 100644 --- a/src/components/attendance/__tests__/EditInstructorDialog.test.tsx +++ b/src/components/attendance/__tests__/EditInstructorDialog.test.tsx @@ -77,8 +77,6 @@ describe('EditInstructorDialog', () => { onOpenChange: vi.fn(), courseCode: 'CS101', courseName: 'Intro to CS', - semester: 'even', - academicYear: '2023-24', }; beforeEach(() => { diff --git a/src/hooks/courses/instructors.ts b/src/hooks/courses/instructors.ts index f026f768..d37cb1eb 100644 --- a/src/hooks/courses/instructors.ts +++ b/src/hooks/courses/instructors.ts @@ -34,9 +34,7 @@ export function useFetchCourseInstructors( const supabase = createClient(); const { data, error } = await supabase .from("course_instructors") - .select("course_code, instructor_name, semester, academic_year") - .eq("semester", semester!) - .eq("academic_year", year!) + .select("course_code, instructor_name") .eq("class_id", classId!); if (error) { diff --git a/src/hooks/courses/useFetchClassCourses.ts b/src/hooks/courses/useFetchClassCourses.ts index a09a1ab6..3c94e8a1 100644 --- a/src/hooks/courses/useFetchClassCourses.ts +++ b/src/hooks/courses/useFetchClassCourses.ts @@ -9,8 +9,8 @@ export interface ClassCourse { id: number; course_code: string; course_name: string; - semester: string; - academic_year: string; + semester?: string; + academic_year?: string; } interface UseFetchClassCoursesOptions { @@ -39,10 +39,8 @@ export function useFetchClassCourses( const supabase = createClient(); const { data, error } = await supabase .from("class_courses") - .select("id, course_code, course_name, semester, academic_year") - .eq("class_id", classId!) - .eq("semester", semester!) - .eq("academic_year", year!); + .select("id, course_code, course_name") + .eq("class_id", classId!); if (error) { throw error; diff --git a/src/hooks/users/settings.ts b/src/hooks/users/settings.ts index 6ddf312b..bf0674ce 100644 --- a/src/hooks/users/settings.ts +++ b/src/hooks/users/settings.ts @@ -60,7 +60,7 @@ export const useFetchAcademicYear = () => { }); }; -export const useSetSemester = () => { +export const useSetSemester = (options?: { skipInvalidations?: boolean }) => { const queryClient = useQueryClient(); return useMutation({ @@ -75,6 +75,8 @@ export const useSetSemester = () => { // 1. Update the Setting Cache immediately queryClient.setQueryData(["semester"], variables.default_semester); + if (options?.skipInvalidations) return; + // 2. Refresh ALL Dependent Data // This ensures courses, attendance, tracking, and scores all switch to the new semester queryClient.invalidateQueries({ queryKey: ["courses"] }); @@ -97,7 +99,7 @@ export const useSetSemester = () => { }); }; -export const useSetAcademicYear = () => { +export const useSetAcademicYear = (options?: { skipInvalidations?: boolean }) => { const queryClient = useQueryClient(); return useMutation({ @@ -114,6 +116,8 @@ export const useSetAcademicYear = () => { variables.default_academic_year ); + if (options?.skipInvalidations) return; + // Refresh ALL Dependent Data queryClient.invalidateQueries({ queryKey: ["courses"] }); queryClient.invalidateQueries({ queryKey: ["attendance-report"] }); diff --git a/supabase/migrations/20260523140000_remove_explicit_sem_year_from_courses_instructors.sql b/supabase/migrations/20260523140000_remove_explicit_sem_year_from_courses_instructors.sql new file mode 100644 index 00000000..94c71827 --- /dev/null +++ b/supabase/migrations/20260523140000_remove_explicit_sem_year_from_courses_instructors.sql @@ -0,0 +1,51 @@ +-- Migration to remove explicit semester and academic_year columns from class_courses and course_instructors tables. +-- Since classes are already tied to a specific semester and year, these columns are redundant. + +BEGIN; + +-- 1) Drop dependent foreign keys first +ALTER TABLE public.course_instructors + DROP CONSTRAINT IF EXISTS course_instructors_class_course_fkey; + +-- 2) Drop unique constraints on class_courses and course_instructors +ALTER TABLE ONLY public.class_courses + DROP CONSTRAINT IF EXISTS unique_class_course_context; + +ALTER TABLE ONLY public.course_instructors + DROP CONSTRAINT IF EXISTS unique_class_course_term; + +-- 3) Drop check constraints on class_courses and course_instructors (if any) +ALTER TABLE public.class_courses + DROP CONSTRAINT IF EXISTS class_courses_academic_year_check, + DROP CONSTRAINT IF EXISTS class_courses_semester_check; + +ALTER TABLE public.course_instructors + DROP CONSTRAINT IF EXISTS course_instructors_academic_year_check, + DROP CONSTRAINT IF EXISTS course_instructors_semester_check; + +-- 4) Drop columns from class_courses +ALTER TABLE public.class_courses + DROP COLUMN IF EXISTS academic_year, + DROP COLUMN IF EXISTS semester; + +-- 5) Drop columns from course_instructors +ALTER TABLE public.course_instructors + DROP COLUMN IF EXISTS academic_year, + DROP COLUMN IF EXISTS semester; + +-- 6) Add new unique constraints without academic_year/semester +ALTER TABLE ONLY public.class_courses + ADD CONSTRAINT unique_class_course_context UNIQUE (class_id, course_code); + +ALTER TABLE ONLY public.course_instructors + ADD CONSTRAINT unique_class_course_term UNIQUE (class_id, course_code); + +-- 7) Add foreign key constraint back +ALTER TABLE public.course_instructors + ADD CONSTRAINT course_instructors_class_course_fkey + FOREIGN KEY (class_id, course_code) + REFERENCES public.class_courses (class_id, course_code) + ON UPDATE CASCADE + ON DELETE CASCADE; + +COMMIT;