From 09901100c5aca54a44d4ce81e3ab08757e129e6d Mon Sep 17 00:00:00 2001 From: Devanarayanan Date: Tue, 19 May 2026 05:31:11 +0000 Subject: [PATCH 01/15] feat: implement instructor upsert API and migrate EditInstructorDialog to use centralized API service with improved error handling --- .example.env | 4 +- mobile/lib/config/app_config.dart | 2 +- mobile/lib/logic/error_utils.dart | 15 ++ mobile/lib/providers/dashboard_provider.dart | 56 ++++++ .../screens/attendance_calendar_screen.dart | 9 +- mobile/lib/services/api_service.dart | 19 +++ mobile/lib/widgets/add_attendance_dialog.dart | 7 +- .../widgets/attendance/add_course_dialog.dart | 7 +- .../attendance/edit_instructor_dialog.dart | 34 ++-- .../dashboard/disable_aware_course_card.dart | 5 +- mobile/pubspec.yaml | 2 +- mobile/test/edit_instructor_dialog_test.dart | 52 ------ mobile/test/logic/error_utils_test.dart | 30 +++- .../edit_instructor_dialog_test.dart | 160 ++++++++++++++++++ next.config.ts | 30 +++- package-lock.json | 4 +- package.json | 2 +- public/openapi/openapi.yaml | 2 +- src/app/api/courses/add/route.ts | 33 +++- .../upsert/__tests__/route.test.ts | 130 ++++++++++++++ src/app/api/instructors/upsert/route.ts | 97 +++++++++++ 21 files changed, 602 insertions(+), 98 deletions(-) delete mode 100644 mobile/test/edit_instructor_dialog_test.dart create mode 100644 mobile/test/widgets/attendance/edit_instructor_dialog_test.dart create mode 100644 src/app/api/instructors/upsert/__tests__/route.test.ts create mode 100644 src/app/api/instructors/upsert/route.ts diff --git a/.example.env b/.example.env index 96ce20d2..a5ece97e 100644 --- a/.example.env +++ b/.example.env @@ -44,11 +44,11 @@ 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.6 +NEXT_PUBLIC_APP_VERSION=4.3.7 # ⚠️ Minimum supported app version required to bypass forced update # πŸš€ Runtime (Infisical `/runtime` folder β†’ Server Env Var) -MIN_APP_VERSION=4.3.6 +MIN_APP_VERSION=4.3.7 # ⚠️ Your production domain WITHOUT https:// # All URL-based variables are derived from this. diff --git a/mobile/lib/config/app_config.dart b/mobile/lib/config/app_config.dart index a17139b9..83d906af 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.6'); + const String.fromEnvironment('APP_VERSION', defaultValue: '4.3.7'); /// Commit SHA injected by CI for release builds. static String get appCommitSha => diff --git a/mobile/lib/logic/error_utils.dart b/mobile/lib/logic/error_utils.dart index 0dced3e1..b13dfa01 100644 --- a/mobile/lib/logic/error_utils.dart +++ b/mobile/lib/logic/error_utils.dart @@ -53,6 +53,21 @@ String formatApiError(dynamic response, String context) { final lower = message.toLowerCase(); + // 401 Unauthorized / Session Expired + if (status == 401 || + code == '401' || + lower.contains('unauthorized') || + lower.contains('unauthenticated')) { + return 'Session expired. Please log in again.'; + } + + // 403 Forbidden / Access Denied + if (status == 403 || + code == '403' || + lower.contains('forbidden')) { + return 'Access denied. Permission required.'; + } + // Supabase/Auth decoding errors (often due to proxy/ISP blocks or firewalls) if (lower.contains('failed to decode error response') || lower.contains('unexpected character') || diff --git a/mobile/lib/providers/dashboard_provider.dart b/mobile/lib/providers/dashboard_provider.dart index 68c762ea..0e81db3d 100644 --- a/mobile/lib/providers/dashboard_provider.dart +++ b/mobile/lib/providers/dashboard_provider.dart @@ -583,6 +583,62 @@ class DashboardNotifier extends AsyncNotifier { }); } + Future updateLocalInstructor( + String courseCode, + String instructorName, + ) async { + final user = ref.read(authProvider).value; + final academic = ref.read(academicProvider).value; + if (user == null || academic == null) return; + + final stdCode = courseCode.toUpperCase().replaceAll(' ', ''); + + // 1. Update in-memory cache + final updatedList = List.from(_cachedInstructors ?? []); + final index = updatedList.indexWhere( + (i) => i.courseCode.toUpperCase().replaceAll(' ', '') == stdCode, + ); + + final updatedInstructor = CourseInstructor( + courseCode: courseCode, + instructorName: instructorName, + ); + + if (index >= 0) { + updatedList[index] = updatedInstructor; + } else { + updatedList.add(updatedInstructor); + } + _cachedInstructors = updatedList; + + // 2. Persist to disk cache + final storage = ref.read(secureStorageProvider); + final cacheKeySuffix = + '${user.supabaseUserId}_${academic.semester}_${academic.year}'; + await storage.saveCachedData( + 'dashboard_instructors_$cacheKeySuffix', + updatedList.map((i) => i.toJson()).toList(), + ); + + // 3. Update active Riverpod state if it has data + if (state.hasValue) { + final currentData = state.value!; + state = AsyncValue.data( + DashboardData( + courses: currentData.courses, + attendance: currentData.attendance, + tracking: currentData.tracking, + stats: currentData.stats, + selectedSemester: currentData.selectedSemester, + selectedYear: currentData.selectedYear, + instructors: updatedList, + className: currentData.className, + disabledCodes: currentData.disabledCodes, + ), + ); + } + } + Future setSemester(String sem) async { await ref.read(academicProvider.notifier).setSemester(sem); } diff --git a/mobile/lib/screens/attendance_calendar_screen.dart b/mobile/lib/screens/attendance_calendar_screen.dart index 6d720aeb..b126e390 100644 --- a/mobile/lib/screens/attendance_calendar_screen.dart +++ b/mobile/lib/screens/attendance_calendar_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; 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/logic/error_handler.dart'; import 'package:ghostclass/models/attendance.dart'; import 'package:ghostclass/models/course_details.dart'; @@ -549,12 +550,12 @@ class _CalendarContent extends ConsumerWidget { 'Correction added successfully', ); } - } on Object { + } on Object catch (e) { if (context.mounted) { setState(() => isSubmitting = false); ServiceToast.show( context, - 'Failed to add correction', + formatApiError(e, 'attendance'), isError: true, ); } @@ -661,12 +662,12 @@ class _CalendarContent extends ConsumerWidget { 'Record deleted successfully', ); } - } on Object { + } on Object catch (e) { if (context.mounted) { setDialogState(() => isDeleting = false); ServiceToast.show( context, - 'Failed to delete record', + formatApiError(e, 'deleting record'), isError: true, ); } diff --git a/mobile/lib/services/api_service.dart b/mobile/lib/services/api_service.dart index 7482487d..745b6edb 100644 --- a/mobile/lib/services/api_service.dart +++ b/mobile/lib/services/api_service.dart @@ -212,6 +212,25 @@ class ApiService { ); } + Future> upsertInstructor({ + required String courseCode, + required String instructorName, + required String semester, + required String academicYear, + required String supabaseToken, + }) async { + return client.post( + '${AppConfig.ghostclassApiUrl}/instructors/upsert', + data: { + 'courseCode': courseCode, + 'instructorName': instructorName, + 'semester': semester, + 'academicYear': academicYear, + }, + options: Options(headers: {'Authorization': 'Bearer $supabaseToken'}), + ); + } + // --- Error Handling --- AppException mapDioError(DioException e) { final status = e.response?.statusCode; diff --git a/mobile/lib/widgets/add_attendance_dialog.dart b/mobile/lib/widgets/add_attendance_dialog.dart index 9be6e41a..7a22bf2c 100644 --- a/mobile/lib/widgets/add_attendance_dialog.dart +++ b/mobile/lib/widgets/add_attendance_dialog.dart @@ -12,6 +12,7 @@ import 'package:ghostclass/providers/tracking_provider.dart'; import 'package:ghostclass/services/logger.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:ghostclass/widgets/attendance/attendance_dialog_widgets.dart'; +import 'package:ghostclass/logic/error_utils.dart'; import 'package:ghostclass/widgets/service_toast.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:intl/intl.dart'; @@ -815,7 +816,11 @@ class _AddAttendanceDialogState extends ConsumerState { } on Object catch (e, st) { AppLogger.e('AddAttendanceDialog: Insert failed', e, st); if (mounted) { - ServiceToast.show(context, 'Failed to add record', isError: true); + ServiceToast.show( + context, + formatApiError(e, 'attendance'), + isError: true, + ); } } finally { if (mounted) setState(() => _isSubmitting = false); diff --git a/mobile/lib/widgets/attendance/add_course_dialog.dart b/mobile/lib/widgets/attendance/add_course_dialog.dart index f2079cff..9319e513 100644 --- a/mobile/lib/widgets/attendance/add_course_dialog.dart +++ b/mobile/lib/widgets/attendance/add_course_dialog.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ghostclass/logic/error_utils.dart'; import 'package:ghostclass/providers/dashboard_provider.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/widgets/service_toast.dart'; @@ -73,7 +74,11 @@ class _AddCourseDialogState extends ConsumerState { Navigator.pop(context); } on Object catch (e) { if (!mounted) return; - ServiceToast.show(context, e.toString(), isError: true); + ServiceToast.show( + context, + formatApiError(e, 'adding course'), + isError: true, + ); } finally { if (mounted) setState(() => _isSubmitting = false); } diff --git a/mobile/lib/widgets/attendance/edit_instructor_dialog.dart b/mobile/lib/widgets/attendance/edit_instructor_dialog.dart index 0e69bd90..3e6df3ee 100644 --- a/mobile/lib/widgets/attendance/edit_instructor_dialog.dart +++ b/mobile/lib/widgets/attendance/edit_instructor_dialog.dart @@ -4,11 +4,12 @@ import 'package:ghostclass/logic/attendance_utils.dart' as utils; import 'package:ghostclass/providers/academic_provider.dart'; import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/providers/dashboard_provider.dart'; +import 'package:ghostclass/logic/error_utils.dart'; +import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/logger.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; -import 'package:supabase_flutter/supabase_flutter.dart' as supabase; class EditInstructorDialog extends ConsumerStatefulWidget { const EditInstructorDialog({ @@ -58,19 +59,24 @@ class _EditInstructorDialogState extends ConsumerState { final auth = ref.read(authProvider).value; if (academic == null || auth == null) throw Exception('Missing context'); - final client = supabase.Supabase.instance.client; + final apiService = ref.read(apiServiceProvider); + final client = ref.read(supabaseClientProvider); + final supabaseToken = client.auth.currentSession?.accessToken; + if (supabaseToken == null) throw Exception('Not authenticated'); - await client.from('course_instructors').upsert({ - 'class_id': auth.profile?.classField?.id, - 'course_code': widget.courseCode.toUpperCase().replaceAll(' ', ''), - 'semester': academic.semester, - 'academic_year': academic.year, - 'instructor_name': name, - 'updated_by': auth.supabaseUserId, - }, onConflict: 'class_id, course_code, semester, academic_year'); + await apiService.upsertInstructor( + courseCode: widget.courseCode, + instructorName: name, + semester: academic.semester, + academicYear: academic.year, + supabaseToken: supabaseToken, + ); - // Refresh dashboard to show new name - await ref.read(dashboardProvider.notifier).refresh(); + // Update dashboard locally to show new name without full refresh + await ref.read(dashboardProvider.notifier).updateLocalInstructor( + widget.courseCode, + name, + ); if (mounted) Navigator.pop(context); } on Object catch (e, st) { @@ -86,9 +92,9 @@ class _EditInstructorDialogState extends ConsumerState { ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( + SnackBar( content: Text( - 'We encountered an error while saving the instructor. Please try again later. If the issue persists, please contact us.', + formatApiError(e, 'saving instructor'), ), ), ); diff --git a/mobile/lib/widgets/dashboard/disable_aware_course_card.dart b/mobile/lib/widgets/dashboard/disable_aware_course_card.dart index 2d585fdd..55248393 100644 --- a/mobile/lib/widgets/dashboard/disable_aware_course_card.dart +++ b/mobile/lib/widgets/dashboard/disable_aware_course_card.dart @@ -8,6 +8,7 @@ import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/services/logger.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:ghostclass/widgets/dashboard/course_card.dart'; +import 'package:ghostclass/logic/error_utils.dart'; import 'package:ghostclass/widgets/service_toast.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -233,7 +234,7 @@ class _DisableAwareCourseCardState setDialogState(() => isSaving = false); ServiceToast.show( context, - 'Failed: $e', + formatApiError(e, 'course configuration'), isError: true, ); } @@ -492,7 +493,7 @@ class _DisableDialogContentState extends State { setState(() => isSaving = false); ServiceToast.show( context, - 'We encountered an error while disabling this course. Please try again later. If the issue persists, please contact us.', + formatApiError(e, 'course configuration'), isError: true, ); } diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 2f8beb44..338acff3 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.6+1 +version: 4.3.7+1 environment: sdk: ^3.11.4 diff --git a/mobile/test/edit_instructor_dialog_test.dart b/mobile/test/edit_instructor_dialog_test.dart deleted file mode 100644 index 50c7cd75..00000000 --- a/mobile/test/edit_instructor_dialog_test.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:ghostclass/widgets/attendance/edit_instructor_dialog.dart'; - -void main() { - testWidgets( - 'EditInstructorDialog shows fields and SAVE disabled when unchanged', - (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Builder( - builder: (context) => Scaffold( - body: Center( - child: ElevatedButton( - onPressed: () => showDialog( - context: context, - builder: (_) => const EditInstructorDialog( - courseCode: 'CS101', - courseName: 'Intro', - initialName: 'Dr. Test', - ), - ), - child: const Text('Open'), - ), - ), - ), - ), - ), - ); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - - expect(find.text('Edit Instructor'), findsOneWidget); - expect(find.text('Instructor Name'), findsOneWidget); - - final saveButton = tester.widget( - find.widgetWithText(ElevatedButton, 'SAVE CHANGES'), - ); - expect(saveButton.onPressed, isNull); - - // Enter a different name and verify SAVE becomes enabled - await tester.enterText(find.byType(TextFormField), 'Dr New'); - await tester.pump(); - - final saveButtonAfter = tester.widget( - find.widgetWithText(ElevatedButton, 'SAVE CHANGES'), - ); - expect(saveButtonAfter.onPressed, isNotNull); - }, - ); -} diff --git a/mobile/test/logic/error_utils_test.dart b/mobile/test/logic/error_utils_test.dart index c828b7d5..9f6394b9 100644 --- a/mobile/test/logic/error_utils_test.dart +++ b/mobile/test/logic/error_utils_test.dart @@ -129,11 +129,11 @@ void main() { test('handles AppException', () { const appErr = AppException( - message: 'Forbidden action', + message: 'Some internal details', type: AppExceptionType.forbidden, statusCode: 403, ); - expect(formatApiError(appErr, 'sync'), 'Forbidden action'); + expect(formatApiError(appErr, 'sync'), 'Access denied. Permission required.'); const emptyAppErr = AppException( message: '', @@ -143,6 +143,32 @@ void main() { expect(formatApiError(emptyAppErr, 'sync'), 'Failed to complete sync'); }); + test('handles 401 and 403 status codes and messages', () { + final dio401 = DioException( + requestOptions: RequestOptions(path: '/test'), + response: Response( + requestOptions: RequestOptions(path: '/test'), + statusCode: 401, + ), + ); + expect( + formatApiError(dio401, 'fetching'), + 'Session expired. Please log in again.', + ); + + final dio403 = DioException( + requestOptions: RequestOptions(path: '/test'), + response: Response( + requestOptions: RequestOptions(path: '/test'), + statusCode: 403, + ), + ); + expect( + formatApiError(dio403, 'fetching'), + 'Access denied. Permission required.', + ); + }); + test('handles various specific error codes and messages', () { expect( formatApiError({'code': '23503'}, 'action'), diff --git a/mobile/test/widgets/attendance/edit_instructor_dialog_test.dart b/mobile/test/widgets/attendance/edit_instructor_dialog_test.dart new file mode 100644 index 00000000..6cfd82f6 --- /dev/null +++ b/mobile/test/widgets/attendance/edit_instructor_dialog_test.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ghostclass/logic/encrypted_value.dart'; +import 'package:ghostclass/models/user.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'; +import 'package:ghostclass/widgets/attendance/edit_instructor_dialog.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:supabase_flutter/supabase_flutter.dart' as supabase; +import 'package:dio/dio.dart'; + +import '../../coverage_helper.dart'; + +class MockApiService extends Mock implements ApiService {} +class MockSupabaseClient extends Mock implements supabase.SupabaseClient {} +class MockGoTrueClient extends Mock implements supabase.GoTrueClient {} +class MockSession extends Mock implements supabase.Session {} + +class TestMockDashboardNotifier extends MockDashboardNotifier { + TestMockDashboardNotifier(super.data); + + @override + Future refresh() async { + // No-op for testing + } + + @override + Future updateLocalInstructor( + String courseCode, + String instructorName, + ) async { + // No-op for testing + } +} + +void main() { + late MockApiService mockApi; + late MockSupabaseClient mockSupabase; + late MockGoTrueClient mockAuth; + late MockSession mockSession; + + setUp(() { + mockApi = MockApiService(); + mockSupabase = MockSupabaseClient(); + mockAuth = MockGoTrueClient(); + mockSession = MockSession(); + + when(() => mockSupabase.auth).thenReturn(mockAuth); + when(() => mockAuth.currentSession).thenReturn(mockSession); + when(() => mockSession.accessToken).thenReturn('test-supabase-token'); + }); + + testWidgets( + 'EditInstructorDialog shows fields, disables SAVE when unchanged, and calls API on save', + (tester) async { + final mockUserVal = AuthenticatedUser( + supabaseUserId: 'user-123', + username: 'testuser', + settings: UserSettings.defaults(), + ezygoToken: EncryptedValue.fromPlaintext('testtoken'), + profile: UserProfile( + firstName: 'Test', + classField: UserClass(id: 'class-456', name: 'Class 456'), + ), + ); + + final mockAcademicVal = const AcademicState( + semester: 'odd', + year: '2024-2025', + ); + + when(() => mockApi.upsertInstructor( + courseCode: any(named: 'courseCode'), + instructorName: any(named: 'instructorName'), + semester: any(named: 'semester'), + academicYear: any(named: 'academicYear'), + supabaseToken: any(named: 'supabaseToken'), + )).thenAnswer((_) async => Response( + requestOptions: RequestOptions(path: '/api/instructors/upsert'), + statusCode: 200, + data: {'message': 'Success'}, + )); + + final overrides = [ + authProvider.overrideWith( + () => MockAuthNotifier(mockUserVal), + ), + academicProvider.overrideWith( + () => MockAcademicNotifier(mockAcademicVal), + ), + apiServiceProvider.overrideWithValue(mockApi), + supabaseClientProvider.overrideWithValue(mockSupabase), + dashboardProvider.overrideWith( + () => TestMockDashboardNotifier(createMockDashboardData()), + ), + ]; + + await tester.pumpWidget( + ProviderScope( + overrides: overrides, + child: MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => showDialog( + context: context, + builder: (_) => const EditInstructorDialog( + courseCode: 'CS101', + courseName: 'Intro', + initialName: 'Dr. Test', + ), + ), + child: const Text('Open'), + ), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Edit Instructor'), findsOneWidget); + expect(find.text('Instructor Name'), findsOneWidget); + + final saveButton = tester.widget( + find.widgetWithText(ElevatedButton, 'SAVE CHANGES'), + ); + expect(saveButton.onPressed, isNull); + + // Enter a different name and verify SAVE becomes enabled + await tester.enterText(find.byType(TextFormField), 'Dr. New'); + await tester.pump(); + + final saveButtonAfter = tester.widget( + find.widgetWithText(ElevatedButton, 'SAVE CHANGES'), + ); + expect(saveButtonAfter.onPressed, isNotNull); + + // Tap SAVE CHANGES + await tester.tap(find.widgetWithText(ElevatedButton, 'SAVE CHANGES')); + await tester.pump(); + + // Verify API was called + verify(() => mockApi.upsertInstructor( + courseCode: 'CS101', + instructorName: 'Dr. New', + semester: 'odd', + academicYear: '2024-2025', + supabaseToken: 'test-supabase-token', + )).called(1); + }, + ); +} diff --git a/next.config.ts b/next.config.ts index 9464fb9c..60be9605 100644 --- a/next.config.ts +++ b/next.config.ts @@ -46,20 +46,32 @@ const allowedImageHostnames = (() => { "avatars.githubusercontent.com", // GitHub "secure.gravatar.com", // Gravatar ]; - if (process.env.NEXT_PUBLIC_SUPABASE_URL) { - try { hosts.push(new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).hostname); } catch { /* ignore */ } - } - if (process.env.NEXT_PUBLIC_SUPABASE_DEV_URL) { - try { hosts.push(new URL(process.env.NEXT_PUBLIC_SUPABASE_DEV_URL).hostname); } catch { /* ignore */ } + + const envUrls = [ + process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.NEXT_PUBLIC_SUPABASE_DEV_URL, + process.env.NEXT_PUBLIC_SUPABASE_CF_PROXY_URL, + process.env.NEXT_PUBLIC_SUPABASE_AWS_PROXY_URL, + process.env.NEXT_PUBLIC_SUPABASE_DEV_PROXY_URL, + ]; + + for (const urlString of envUrls) { + if (urlString) { + try { + hosts.push(new URL(urlString).hostname); + } catch { + /* ignore */ + } + } } - - if (hosts.length === 3 && !process.env.NEXT_PUBLIC_SUPABASE_URL) { - // We only throw if even Supabase is missing, as that's the primary storage + + if (!envUrls.some(Boolean)) { throw new Error( - '[next.config.ts] At least one Supabase URL (NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_DEV_URL) ' + + '[next.config.ts] At least one Supabase URL or Supabase Proxy URL ' + 'is required at build time for images.remotePatterns.' ); } + // Deduplicate return Array.from(new Set(hosts)); })(); diff --git a/package-lock.json b/package-lock.json index 1d2466c0..6ad8eb3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostclass", - "version": "4.3.6", + "version": "4.3.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostclass", - "version": "4.3.6", + "version": "4.3.7", "dependencies": { "@hookform/resolvers": "^5.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", diff --git a/package.json b/package.json index 795303c9..a0c93581 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostclass", - "version": "4.3.6", + "version": "4.3.7", "private": true, "engines": { "node": ">=22.12.0", diff --git a/public/openapi/openapi.yaml b/public/openapi/openapi.yaml index 85ba9af5..970a8fcc 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.6 + version: 4.3.7 description: | **GhostClass API** provides endpoints for authentication, profile synchronization, attendance integrations with EzyGo, telemetry, and build provenance. diff --git a/src/app/api/courses/add/route.ts b/src/app/api/courses/add/route.ts index c58b2e9c..0a7f5eb3 100644 --- a/src/app/api/courses/add/route.ts +++ b/src/app/api/courses/add/route.ts @@ -1,9 +1,33 @@ import { NextResponse } from "next/server"; import { withSecurity } from "@/lib/security/app-check"; import { createClient } from "@/lib/supabase/server"; +import { getAdminClient } from "@/lib/supabase/admin"; import { logger } from "@/lib/logger"; import { toTitleCase } from "@/lib/utils"; +async function authenticateRequest(req: Request) { + const authHeader = req.headers.get("authorization"); + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.split(" ")[1]; + if (!token) return null; + const supabaseAdmin = getAdminClient(); + const { data: { user }, error } = await supabaseAdmin.auth.getUser(token); + if (error || !user) { + logger.error("API Course Add: Bearer auth.getUser error:", error || "No user"); + return null; + } + return { user, supabase: supabaseAdmin }; + } + + const supabaseClient = await createClient(); + const { data: { user }, error } = await supabaseClient.auth.getUser(); + if (error || !user) { + logger.error("API Course Add: Client auth.getUser error:", error || "No user"); + return null; + } + return { user, supabase: supabaseClient }; +} + /** * API Route for adding a new course to a class lineup. * Primarily used by the mobile app which bypasses the Turnstile check @@ -30,14 +54,13 @@ async function handler(req: Request, { decryptedBody }: { decryptedBody?: unknow return NextResponse.json({ error: "Invalid academic year format (expected YYYY-YYYY or YYYY-YY)" }, { status: 400 }); } - const supabase = await createClient(); - - // Get current authenticated user - const { data: { user }, error: authError } = await supabase.auth.getUser(); - if (authError || !user) { + const auth = await authenticateRequest(req); + if (!auth) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + const { user, supabase } = auth; + // Get user's class context const { data: profile, error: profileError } = await supabase .from("users") diff --git a/src/app/api/instructors/upsert/__tests__/route.test.ts b/src/app/api/instructors/upsert/__tests__/route.test.ts new file mode 100644 index 00000000..8129d3a2 --- /dev/null +++ b/src/app/api/instructors/upsert/__tests__/route.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { createClient } from "@/lib/supabase/server"; + +// Mock dependencies +vi.mock("server-only", () => ({})); + +vi.mock("@/lib/supabase/server", () => ({ + createClient: vi.fn(), +})); + +vi.mock("@/lib/security/app-check", () => ({ + withSecurity: vi.fn((handler) => handler), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, +})); + +const MOCK_INSTRUCTOR = { + courseCode: "CS101", + instructorName: "Dr. Jane Smith", + semester: "odd", + academicYear: "2024-2025", +}; + +describe("POST /api/instructors/upsert", () => { + const mockAuthGetUser = vi.fn(); + const mockFrom = vi.fn(); + const mockSelect = vi.fn(); + const mockEq = vi.fn(); + const mockSingle = vi.fn(); + const mockUpsert = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + (createClient as any).mockResolvedValue({ + auth: { getUser: mockAuthGetUser }, + from: mockFrom, + }); + + mockFrom.mockReturnValue({ + select: mockSelect, + upsert: mockUpsert, + }); + mockSelect.mockReturnValue({ + eq: mockEq, + }); + mockEq.mockReturnValue({ + single: mockSingle, + }); + + mockAuthGetUser.mockResolvedValue({ data: { user: { id: "user-123" } }, error: null }); + mockSingle.mockResolvedValue({ data: { class_id: "class-456" }, error: null }); + mockUpsert.mockResolvedValue({ error: null }); + }); + + it("returns 400 when required fields are missing", async () => { + const { POST } = await import("../route"); + const req = new NextRequest("http://localhost/api/instructors/upsert", { + method: "POST", + body: JSON.stringify({ courseCode: "CS101" }) + }); + const res = await POST(req, { params: {} }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Missing required fields" }); + }); + + it("returns 401 when user is not authenticated", async () => { + mockAuthGetUser.mockResolvedValueOnce({ data: { user: null }, error: new Error("Unauthorized") }); + const { POST } = await import("../route"); + const req = new NextRequest("http://localhost/api/instructors/upsert", { + method: "POST", + body: JSON.stringify(MOCK_INSTRUCTOR) + }); + const res = await POST(req, { params: {} }); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: "Unauthorized" }); + }); + + it("returns 400 when no class is associated with profile", async () => { + mockSingle.mockResolvedValueOnce({ data: null, error: { message: "No class" } }); + const { POST } = await import("../route"); + const req = new NextRequest("http://localhost/api/instructors/upsert", { + method: "POST", + body: JSON.stringify(MOCK_INSTRUCTOR) + }); + const res = await POST(req, { params: {} }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "No class associated with your profile" }); + }); + + it("returns 200 when instructor is upserted successfully", async () => { + const { POST } = await import("../route"); + const req = new NextRequest("http://localhost/api/instructors/upsert", { + method: "POST", + body: JSON.stringify(MOCK_INSTRUCTOR) + }); + const res = await POST(req, { params: {} }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ message: "Instructor saved successfully" }); + expect(mockUpsert).toHaveBeenCalledWith({ + 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" + }); + }); + + it("returns 500 when database upsert fails", async () => { + mockUpsert.mockResolvedValueOnce({ error: { message: "Upsert failed" } }); + const { POST } = await import("../route"); + const req = new NextRequest("http://localhost/api/instructors/upsert", { + method: "POST", + body: JSON.stringify(MOCK_INSTRUCTOR) + }); + const res = await POST(req, { params: {} }); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: "Failed to save instructor to database" }); + }); +}); diff --git a/src/app/api/instructors/upsert/route.ts b/src/app/api/instructors/upsert/route.ts new file mode 100644 index 00000000..a46ac6eb --- /dev/null +++ b/src/app/api/instructors/upsert/route.ts @@ -0,0 +1,97 @@ +import { NextResponse, NextRequest } from "next/server"; +import { withSecurity } from "@/lib/security/app-check"; +import { createClient } from "@/lib/supabase/server"; +import { getAdminClient } from "@/lib/supabase/admin"; +import { logger } from "@/lib/logger"; +import { toTitleCase } from "@/lib/utils"; + +async function authenticateRequest(req: Request) { + const authHeader = req.headers.get("authorization"); + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.split(" ")[1]; + if (!token) return null; + const supabaseAdmin = getAdminClient(); + const { data: { user }, error } = await supabaseAdmin.auth.getUser(token); + if (error || !user) { + logger.error("API Instructor Upsert: Bearer auth.getUser error:", error || "No user"); + return null; + } + return { user, supabase: supabaseAdmin }; + } + + const supabaseClient = await createClient(); + const { data: { user }, error } = await supabaseClient.auth.getUser(); + if (error || !user) { + logger.error("API Instructor Upsert: Client auth.getUser error:", error || "No user"); + return null; + } + return { user, supabase: supabaseClient }; +} + +async function handler(req: NextRequest, { decryptedBody }: { decryptedBody?: unknown }) { + try { + const body = decryptedBody || await req.json(); + + const courseCode = String(body.courseCode ?? "").trim().toUpperCase().replace(/[\s\u00A0-]/g, ""); + const instructorName = toTitleCase(String(body.instructorName ?? "")); + const semester = String(body.semester ?? "").trim(); + const academicYear = String(body.academicYear ?? "").trim(); + + if (!courseCode || !instructorName || !semester || !academicYear) { + return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); + } + + if (semester !== "odd" && semester !== "even") { + return NextResponse.json({ error: "Semester must be 'odd' or 'even'" }, { status: 400 }); + } + + if (!/^\d{4}-(\d{4}|\d{2})$/.test(academicYear)) { + return NextResponse.json({ error: "Invalid academic year format (expected YYYY-YYYY or YYYY-YY)" }, { status: 400 }); + } + + const auth = await authenticateRequest(req); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { user, supabase } = auth; + + // Get user's class context + const { data: profile, error: profileError } = await supabase + .from("users") + .select("class_id") + .eq("auth_id", user.id) + .single(); + + if (profileError || !profile?.class_id) { + logger.error("API Instructor Upsert: Failed to fetch user class", profileError); + return NextResponse.json({ error: "No class associated with your profile" }, { status: 400 }); + } + + // Upsert into course_instructors (communal mapping shared by the class) + const { error: upsertError } = await supabase + .from("course_instructors") + .upsert({ + 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" + }); + + if (upsertError) { + logger.error("API Instructor Upsert: Database upsert failed", upsertError); + return NextResponse.json({ error: "Failed to save instructor to database" }, { status: 500 }); + } + + return NextResponse.json({ message: "Instructor saved successfully" }, { status: 200 }); + } catch (error) { + logger.error("API Instructor Upsert: Unexpected error", error); + return NextResponse.json({ error: "An internal error occurred" }, { status: 500 }); + } +} + +export const POST = withSecurity(handler); From 9157d06d42a04830444e28133117939d230bbecd Mon Sep 17 00:00:00 2001 From: Devanarayanan Date: Tue, 19 May 2026 07:48:51 +0000 Subject: [PATCH 02/15] fix/refactor: update exam batch API endpoint, adjust UI layouts, and bump ws dependency version proxy workers --- .../(protected)/dashboard/DashboardClient.tsx | 10 ++- .../leave-applications/LeaveClient.tsx | 2 +- src/hooks/courses/__tests__/exams.test.tsx | 2 +- src/hooks/courses/exams.ts | 2 +- workers/package-lock.json | 72 +++++++++---------- workers/package.json | 3 +- 6 files changed, 49 insertions(+), 42 deletions(-) diff --git a/src/app/(protected)/dashboard/DashboardClient.tsx b/src/app/(protected)/dashboard/DashboardClient.tsx index 5112d37c..ba06c413 100644 --- a/src/app/(protected)/dashboard/DashboardClient.tsx +++ b/src/app/(protected)/dashboard/DashboardClient.tsx @@ -417,9 +417,15 @@ export default function DashboardClient({ initialData, serverError }: DashboardC {isGlobalLoading && (

Syncing...

)}
-
+

Welcome back, {profile?.first_name} {profile?.last_name}!

-
+ + {profile?.class?.name || "Unassigned"} + +

+ For students juggling classes, internals, labs, submissions, caffeine, and β€œI’ll study tomorrow” energy β˜•πŸ“š +

+

Semester: enforces YYYY-MM-DD but this makes it explicit at - // the Zod layer so API submissions and legacy data are also validated. - .refine( - (val) => !val || /^\d{4}-\d{2}-\d{2}$/.test(val), - "Birth date must be in YYYY-MM-DD format", - ) .refine((val) => { if (!val) return true; @@ -67,7 +56,12 @@ const profileFormSchema = z.object({ }, "Birth date cannot be in the future"), }); -type ProfileFormValues = z.infer; +type ProfileFormInput = { + first_name: string; + last_name: string | null; + gender: "" | "male" | "female" | "other"; + birth_date: string | null; +}; interface ReadOnlyFieldProps extends Omit, 'value'> { value?: string | null; @@ -117,7 +111,7 @@ export function ProfileForm({ profile }: { profile: UserProfile }) { const getGenderValue = (val: string | undefined | null) => { if (!val) return ""; - return val.toLowerCase(); + return val.toLowerCase() as ProfileFormInput["gender"]; }; const displayGender = (val: string | undefined | null) => { @@ -125,8 +119,8 @@ export function ProfileForm({ profile }: { profile: UserProfile }) { return val.charAt(0).toUpperCase() + val.slice(1); }; - const form = useForm({ - resolver: zodResolver(profileFormSchema), + const form = useForm({ + resolver: zodResolver(profileFormSchema) as unknown as Resolver, defaultValues: { first_name: "", last_name: "", @@ -144,15 +138,12 @@ export function ProfileForm({ profile }: { profile: UserProfile }) { } }); - function onSubmit(formValues: ProfileFormValues) { - const sanitizedFirstName = toTitleCase(formValues.first_name); - const sanitizedLastName = formValues.last_name ? toTitleCase(formValues.last_name) : null; - + function onSubmit(formValues: ProfileFormInput) { updateProfileMutation.mutate( { data: { - first_name: sanitizedFirstName, - last_name: sanitizedLastName, + first_name: formValues.first_name, + last_name: formValues.last_name?.trim() || null, gender: formValues.gender || null, birth_date: formValues.birth_date || null, } @@ -259,6 +250,7 @@ export function ProfileForm({ profile }: { profile: UserProfile }) { placeholder="Enter last name" className="pl-9 custom-input bg-background/50 h-11" {...field} + value={field.value ?? ""} /> ) : ( diff --git a/src/hooks/courses/useDisabledCourses.ts b/src/hooks/courses/useDisabledCourses.ts index 9251d66c..6528b1ee 100644 --- a/src/hooks/courses/useDisabledCourses.ts +++ b/src/hooks/courses/useDisabledCourses.ts @@ -3,6 +3,7 @@ import { useCallback, useMemo } from "react"; import { useUserSettings } from "@/providers/user-settings"; import { DisabledCoursesMap } from "@/types/user-settings"; +import { reasonTextSchema } from "@/lib/validation/text"; /** * Generates the semester key used as the top-level key in `disabled_courses`. @@ -107,12 +108,13 @@ export function useDisabledCourses({ const disableCourse = useCallback( async (code: string, reason: string) => { if (!semKey) return; + const safeReason = reasonTextSchema.parse(reason); const newMap: DisabledCoursesMap = structuredClone(disabledCoursesMap); if (!Object.prototype.hasOwnProperty.call(newMap, semKey)) { Reflect.set(newMap, semKey, {}); } const semMap = Reflect.get(newMap, semKey)!; - Reflect.set(semMap, code.toUpperCase(), reason); + Reflect.set(semMap, code.toUpperCase(), safeReason); await updateDisabledCourses(newMap); }, [semKey, disabledCoursesMap, updateDisabledCourses] diff --git a/src/lib/contact/service.ts b/src/lib/contact/service.ts index bd3e8bde..8b2c0237 100644 --- a/src/lib/contact/service.ts +++ b/src/lib/contact/service.ts @@ -8,28 +8,14 @@ import { logger } from "@/lib/logger"; import * as Sentry from "@sentry/nextjs"; import sanitizeHtml from "sanitize-html"; import { redact } from "@/lib/utils.server"; +import { emailSchema, longTextSchema, personNameSchema, shortTextSchema } from "@/lib/validation/text"; // VALIDATION SCHEMA export const contactSchema = z.object({ - name: z.string() - .min(2, "Name must be at least 2 characters") - .max(100, "Name too long") - .regex(/^[a-zA-Z\s'-]+$/, "Name contains invalid characters"), - - email: z - .email("Invalid email format") - .max(255, "Email too long") - .transform(val => val.toLowerCase().trim()), - - subject: z.string() - .max(200, "Subject too long") - .transform(val => val.trim()) - .optional(), - - message: z.string() - .min(10, "Message must be at least 10 characters") - .max(5000, "Message too long") - .transform(val => val.trim()), + name: personNameSchema, + email: emailSchema, + subject: shortTextSchema.optional().nullable(), + message: longTextSchema, token: z.string().optional(), csrf_token: z.string().optional(), @@ -106,10 +92,10 @@ export async function processContactSubmission( .from("contact_messages") .insert({ user_id: ctx.userId ?? null, - name: payload.name.trim(), - email: payload.email.trim().toLowerCase(), - subject: payload.subject?.trim() || "New Contact Form Submission", - message: payload.message.trim(), + name: payload.name, + email: payload.email, + subject: payload.subject ?? "New Contact Form Submission", + message: payload.message, status: "new", }) .select("id") diff --git a/src/lib/user/sync.ts b/src/lib/user/sync.ts index 8bb43f96..259cc26e 100644 --- a/src/lib/user/sync.ts +++ b/src/lib/user/sync.ts @@ -5,6 +5,7 @@ import { decrypt, encrypt } from "@/lib/crypto"; import * as Sentry from "@sentry/nextjs"; import { safeResponseJson } from "@/lib/json"; import { calculateCurrentAcademicInfo } from "@/lib/logic/academic"; +import { ezygoProfileSchema, shortTextSchema } from "@/lib/validation/text"; function safeGet(obj: unknown, prop: string): unknown { return obj && typeof obj === "object" ? Reflect.get(obj, prop) : undefined; @@ -12,20 +13,20 @@ function safeGet(obj: unknown, prop: string): unknown { interface EzygoProfileResponse { user_id?: string | number; - username?: string; - email?: string; - mobile?: string; - first_name?: string; - last_name?: string; - full_name?: string; - gender?: string; - sex?: string; - birth_date?: string; - dob?: string; + username?: string | null; + email?: string | null; + mobile?: string | null; + first_name?: string | null; + last_name?: string | null; + full_name?: string | null; + gender?: string | null; + sex?: string | null; + birth_date?: string | null; + dob?: string | null; user?: { - username?: string; - email?: string; - mobile?: string; + username?: string | null; + email?: string | null; + mobile?: string | null; id?: string | number; }; } @@ -190,9 +191,10 @@ async function upsertClass( externalId: string | number, name: string, ): Promise<{ id: string; name: string } | null> { + const safeName = shortTextSchema.parse(name); const { data, error } = await supabaseAdmin .from("classes") - .upsert({ external_group_id: externalId, name }, { onConflict: "external_group_id" }) + .upsert({ external_group_id: externalId, name: safeName }, { onConflict: "external_group_id" }) .select("id, name") .single(); if (error || !data) return null; @@ -463,7 +465,11 @@ async function parseProfileResponse( if (!json) { throw new Error(`EzyGo Profile returned empty or invalid JSON: ${ezygoRes?.status}`); } - const ezygoData = (safeGet(json, "data") ?? json) as EzygoProfileResponse; + const parsedProfile = ezygoProfileSchema.safeParse(safeGet(json, "data") ?? json); + if (!parsedProfile.success) { + throw new Error(`EzyGo Profile returned invalid data: ${ezygoRes?.status}`); + } + const ezygoData = parsedProfile.data; const resolvedEzygoId = (ezygoId && String(ezygoId).trim() !== "") ? String(ezygoId) diff --git a/src/lib/validation/text.ts b/src/lib/validation/text.ts new file mode 100644 index 00000000..84c682c0 --- /dev/null +++ b/src/lib/validation/text.ts @@ -0,0 +1,174 @@ +import { z } from "zod"; +import { toTitleCase } from "@/lib/utils"; + +function stripControlChars(value: string): string { + let result = ""; + for (const char of value) { + const code = char.charCodeAt(0); + if (code < 32 || code === 127) continue; + result += char; + } + return result; +} + +function sanitizeText(value: string): string { + return stripControlChars(value).replace(/\s+/g, " ").trim(); +} + +function normalizeTextValue(value: unknown, collapseWhitespace: boolean): unknown { + if (typeof value !== "string") return value; + const sanitized = collapseWhitespace + ? sanitizeText(value) + : stripControlChars(value).trim(); + return sanitized === "" ? null : sanitized; +} + +type TextSchemaOptions = { + min?: number; + max?: number; + pattern?: RegExp; + error?: string; + collapseWhitespace?: boolean; +}; + +function makeTextSchema({ min, max, pattern, error, collapseWhitespace = true }: TextSchemaOptions = {}) { + let schema = z.string(); + + if (typeof min === "number") { + schema = schema.min(min, error); + } + if (typeof max === "number") { + schema = schema.max(max, error); + } + if (pattern) { + schema = schema.regex(pattern, error); + } + + return z.preprocess( + (value) => { + if (typeof value !== "string") return value; + return collapseWhitespace ? sanitizeText(value) : stripControlChars(value).trim(); + }, + schema, + ); +} + +export function makeOptionalTextSchema(options: TextSchemaOptions = {}) { + const collapseWhitespace = options.collapseWhitespace !== false; + return z.preprocess( + (value) => normalizeTextValue(value, collapseWhitespace), + makeTextSchema(options).nullish(), + ).transform((value) => value ?? null); +} + +const NAME_PATTERN = /^[\p{L}\p{M}.'’\- ]+$/u; + +export const personNameSchema = makeTextSchema({ + min: 2, + max: 100, + pattern: NAME_PATTERN, + error: "Name contains invalid characters", +}).transform((value) => toTitleCase(value)); + +export const optionalPersonNameSchema = makeOptionalTextSchema({ + min: 2, + max: 100, + pattern: NAME_PATTERN, + error: "Name contains invalid characters", +}).transform((value) => (value ? toTitleCase(value) : null)); + +export const shortTextSchema = makeTextSchema({ + min: 1, + max: 200, +}); + +export const optionalShortTextSchema = makeOptionalTextSchema({ + min: 1, + max: 200, +}); + +export const longTextSchema = makeTextSchema({ + min: 10, + max: 5000, + collapseWhitespace: false, +}); + +export const optionalReasonSchema = makeOptionalTextSchema({ + min: 1, + max: 255, +}); + +export const reasonTextSchema = makeTextSchema({ + min: 1, + max: 255, +}); + +export const emailSchema = z.string().trim().email("Invalid email format").max(255, "Email too long").transform((value) => value.toLowerCase()); + +export const courseCodeSchema = z.string().trim().min(1, "Course code is required").max(32, "Course code too long").transform((value) => value.toUpperCase().replace(/[\s\u00A0-]/g, "")); + +export const academicYearSchema = z.string().trim().regex(/^\d{4}-(\d{4}|\d{2})$/, "Invalid academic year format (expected YYYY-YYYY or YYYY-YY)"); + +export const semesterSchema = z.enum(["odd", "even"]); + +export const genderSchema = z.enum(["male", "female", "other"]); + +export const birthDateSchema = z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/, "Birth date must be in YYYY-MM-DD format"); + +export const ezygoUsernameSchema = makeTextSchema({ + min: 1, + max: 100, +}); + +export const ezygoNameSchema = makeOptionalTextSchema({ + min: 1, + max: 100, + pattern: NAME_PATTERN, + error: "Name contains invalid characters", +}).transform((value) => (value ? toTitleCase(value) : null)); + +export const ezygoTextSchema = makeOptionalTextSchema({ + min: 1, + max: 255, +}); + +export const ezygoGenderSchema = makeOptionalTextSchema({ + min: 1, + max: 32, +}); + +export const ezygoBirthDateSchema = makeOptionalTextSchema({ + min: 1, + max: 10, + pattern: /^\d{4}-\d{2}-\d{2}$/, + error: "Birth date must be in YYYY-MM-DD format", +}); + +export const ezygoProfileSchema = z.object({ + user_id: z.union([z.string(), z.number()]).transform((value) => String(value)).optional(), + username: ezygoUsernameSchema.optional().nullable(), + email: emailSchema.optional().nullable(), + mobile: ezygoTextSchema.optional().nullable(), + first_name: ezygoNameSchema.optional().nullable(), + last_name: ezygoNameSchema.optional().nullable(), + full_name: ezygoNameSchema.optional().nullable(), + gender: ezygoGenderSchema.optional().nullable(), + sex: ezygoGenderSchema.optional().nullable(), + birth_date: ezygoBirthDateSchema.optional().nullable(), + dob: ezygoBirthDateSchema.optional().nullable(), + user: z.object({ + username: ezygoUsernameSchema.optional().nullable(), + email: emailSchema.optional().nullable(), + mobile: ezygoTextSchema.optional().nullable(), + id: z.union([z.string(), z.number()]).transform((value) => String(value)).optional(), + }).partial().optional(), +}).passthrough(); + +export const disabledCoursesSchema = z.record( + z.string(), + z.record(z.string(), reasonTextSchema), +); + +export function sanitizePlainText(value: string): string { + return sanitizeText(value); +} diff --git a/src/providers/user-settings.tsx b/src/providers/user-settings.tsx index c5dcd55b..a3c4c8bc 100644 --- a/src/providers/user-settings.tsx +++ b/src/providers/user-settings.tsx @@ -20,6 +20,7 @@ import { createClient } from "@/lib/supabase/client"; import { toast } from "sonner"; import * as Sentry from "@sentry/nextjs"; import { logger } from "@/lib/logger"; +import { disabledCoursesSchema } from "@/lib/validation/text"; // --------------------------------------------------------------------------- // Types & Constants @@ -71,7 +72,11 @@ const parsePrefetchedSession = (currentUserId: string): UserSettings | null => { if (typeof s.bunk_calculator_enabled === 'boolean' && typeof s.target_percentage === 'number' && s.disabled_courses) { - return s as UserSettings; + const disabledCourses = disabledCoursesSchema.safeParse(s.disabled_courses); + return { + ...s, + disabled_courses: disabledCourses.success ? disabledCourses.data : {}, + } as UserSettings; } } } catch { @@ -214,6 +219,7 @@ function useUserSettingsState() { .upsert({ user_id: userId, ...updates, + ...(updates.disabled_courses ? { disabled_courses: disabledCoursesSchema.parse(updates.disabled_courses) } : {}), updated_at: new Date().toISOString(), }); From eb898d373149f5a4f24c6babdd1925a94daa25e4 Mon Sep 17 00:00:00 2001 From: Devanarayanan Date: Thu, 21 May 2026 12:22:32 +0000 Subject: [PATCH 10/15] refactor: improve input validation and scrolling performance for navbar visibility management --- src/app/(protected)/layout.tsx | 9 ++++++--- src/app/actions/courses.ts | 26 +++++++++++++++++-------- src/app/actions/instructors.ts | 26 +++++++++++++++++-------- src/app/api/courses/add/route.ts | 18 +++++++++++++---- src/app/api/instructors/upsert/route.ts | 18 +++++++++++++---- 5 files changed, 70 insertions(+), 27 deletions(-) diff --git a/src/app/(protected)/layout.tsx b/src/app/(protected)/layout.tsx index a1bfb190..d619c58a 100644 --- a/src/app/(protected)/layout.tsx +++ b/src/app/(protected)/layout.tsx @@ -35,6 +35,7 @@ export default function ProtectedLayout({ const { scrollY } = useScroll(); const lastScrollY = useRef(0); const ticking = useRef(false); + const isHiddenRef = useRef(false); const supabaseRef = useRef(createClient()); // Initialize CSRF token @@ -48,9 +49,11 @@ export default function ProtectedLayout({ const shouldHide = latest > previous && latest > 150; const shouldShow = latest <= previous || latest <= 150; - if (shouldHide && !isHidden) { + if (shouldHide && !isHiddenRef.current) { + isHiddenRef.current = true; setIsHidden(true); - } else if (shouldShow && isHidden) { + } else if (shouldShow && isHiddenRef.current) { + isHiddenRef.current = false; setIsHidden(false); } @@ -64,7 +67,7 @@ export default function ProtectedLayout({ return () => { unsubscribe(); }; - }, [scrollY, isHidden]); + }, [scrollY]); // useInstitutions is also called inside ; React Query deduplicates the // network request so there is no extra fetch here. We only subscribe to it at diff --git a/src/app/actions/courses.ts b/src/app/actions/courses.ts index c0ed9eec..dd1e8bad 100644 --- a/src/app/actions/courses.ts +++ b/src/app/actions/courses.ts @@ -8,6 +8,20 @@ import { z } from "zod"; import { academicYearSchema, courseCodeSchema, personNameSchema, semesterSchema } 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() === "" + ) { + return { error: "Course code, name, semester, and academic year are required" }; + } + // Strict sanitization: Trim all inputs, capitalize and strip spaces from code, title case the name. const parsed = z.object({ courseCode: courseCodeSchema, @@ -15,10 +29,10 @@ export async function addCourseAction(formData: FormData): Promise<{ error?: str semester: semesterSchema, academicYear: academicYearSchema, }).safeParse({ - courseCode: formData.get("courseCode"), - courseName: formData.get("courseName"), - semester: formData.get("semester"), - academicYear: formData.get("academicYear"), + courseCode: courseCodeValue, + courseName: courseNameValue, + semester: semesterValue, + academicYear: academicYearValue, }); if (!parsed.success) { @@ -28,10 +42,6 @@ export async function addCourseAction(formData: FormData): Promise<{ error?: str const { courseCode: code, courseName: name, semester, academicYear } = parsed.data; const turnstileToken = String(formData.get("cf-turnstile-response") ?? ""); - if (!code || !name || !semester || !academicYear) { - return { error: "Course code, name, semester, and academic year are required" }; - } - // 1. Verify Turnstile Security Token if (!turnstileToken) { return { error: "Security verification failed. Please refresh." }; diff --git a/src/app/actions/instructors.ts b/src/app/actions/instructors.ts index 3458e60c..bb087be9 100644 --- a/src/app/actions/instructors.ts +++ b/src/app/actions/instructors.ts @@ -10,6 +10,20 @@ import { academicYearSchema, courseCodeSchema, personNameSchema, semesterSchema 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() === "" + ) { + return { error: "Course code, instructor name, semester, and academic year are required" }; + } + // Strict sanitization: Trim all inputs, capitalize and strip spaces from code, title case the name. const parsed = z.object({ courseCode: courseCodeSchema, @@ -17,10 +31,10 @@ export async function upsertInstructorAction( semester: semesterSchema, academicYear: academicYearSchema, }).safeParse({ - courseCode: formData.get("courseCode"), - instructorName: formData.get("instructorName"), - semester: formData.get("semester"), - academicYear: formData.get("academicYear"), + courseCode: courseCodeValue, + instructorName: instructorNameValue, + semester: semesterValue, + academicYear: academicYearValue, }); if (!parsed.success) { @@ -30,10 +44,6 @@ export async function upsertInstructorAction( const { courseCode, instructorName, semester, academicYear } = parsed.data; const turnstileToken = String(formData.get("cf-turnstile-response") ?? ""); - if (!courseCode || !instructorName || !semester || !academicYear) { - return { error: "Course code, instructor name, semester, and academic year are required" }; - } - // 1. Verify Turnstile Security Token if (!turnstileToken) { return { error: "Security verification failed. Please refresh." }; diff --git a/src/app/api/courses/add/route.ts b/src/app/api/courses/add/route.ts index 48429e20..02f40dcb 100644 --- a/src/app/api/courses/add/route.ts +++ b/src/app/api/courses/add/route.ts @@ -37,6 +37,20 @@ async function authenticateRequest(req: Request) { async function handler(req: Request, { decryptedBody }: { decryptedBody?: unknown }) { try { const body = decryptedBody || await req.json(); + 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() === "" + ) { + return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); + } const parsed = z.object({ courseCode: courseCodeSchema, @@ -51,10 +65,6 @@ async function handler(req: Request, { decryptedBody }: { decryptedBody?: unknow const { courseCode: code, courseName: name, semester, academicYear } = parsed.data; - if (!code || !name || !semester || !academicYear) { - return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); - } - const auth = await authenticateRequest(req); if (!auth) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/src/app/api/instructors/upsert/route.ts b/src/app/api/instructors/upsert/route.ts index c0de6e36..aef2916c 100644 --- a/src/app/api/instructors/upsert/route.ts +++ b/src/app/api/instructors/upsert/route.ts @@ -32,6 +32,20 @@ async function authenticateRequest(req: Request) { async function handler(req: NextRequest, { decryptedBody }: { decryptedBody?: unknown }) { try { const body = decryptedBody || await req.json(); + 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() === "" + ) { + return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); + } const parsed = z.object({ courseCode: courseCodeSchema, @@ -46,10 +60,6 @@ async function handler(req: NextRequest, { decryptedBody }: { decryptedBody?: un const { courseCode, instructorName, semester, academicYear } = parsed.data; - if (!courseCode || !instructorName || !semester || !academicYear) { - return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); - } - const auth = await authenticateRequest(req); if (!auth) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); From aee6de0149ca0bf56b9cffefedc7f39fc3890403 Mon Sep 17 00:00:00 2001 From: Devanarayanan Date: Fri, 22 May 2026 12:35:04 +0000 Subject: [PATCH 11/15] feat: implement semester/year scoped class management with new database schema and UI selectors fix: improve supabase env selection --- mobile/lib/logic/attendance_utils.dart | 6 + mobile/lib/providers/academic_provider.dart | 126 +++-- mobile/lib/providers/auth_provider.dart | 79 ++- mobile/lib/providers/dashboard_provider.dart | 15 + mobile/lib/screens/dashboard_screen.dart | 34 +- mobile/lib/services/api_service.dart | 6 +- mobile/lib/widgets/add_attendance_dialog.dart | 61 ++- .../widgets/attendance/add_course_dialog.dart | 9 +- .../dashboard/class_selection_dialog.dart | 404 ++++++++++++++ .../dashboard/course_list_section.dart | 235 +++++---- .../lib/widgets/dashboard/header_section.dart | 493 +++++++++--------- .../providers/academic_provider_test.dart | 62 +++ .../(protected)/dashboard/DashboardClient.tsx | 182 +++++-- .../DashboardClient.coverage.test.tsx | 39 +- .../__tests__/DashboardClient.test.tsx | 10 +- src/app/actions/__tests__/courses.test.ts | 2 +- src/app/actions/courses.ts | 4 +- src/app/actions/user.ts | 32 ++ src/app/api/logout/route.ts | 7 +- src/app/api/profile/route.ts | 31 +- .../attendance/AddAttendanceDialog.tsx | 6 +- .../attendance/SelectClassDialog.tsx | 176 +++++++ src/components/service-error-view.tsx | 30 +- src/hooks/users/upload-avatar.ts | 6 +- src/lib/csp.ts | 16 +- src/lib/user/__tests__/sync.test.ts | 230 +++++++- src/lib/user/sync.ts | 359 +++++++++---- src/lib/validation/text.ts | 8 + src/types/exam.d.ts | 2 - .../migrations/20260523000001_placeholder.sql | 0 .../migrations/20260523000002_placeholder.sql | 0 ...0523000003_classes_sem_scoped_identity.sql | 18 + supabase/migrations/20260523000004_drop.sql | 1 + .../20260523000005_drop_classes_end_year.sql | 5 + .../20260523000006_unique_classes_lookup.sql | 5 + 35 files changed, 2030 insertions(+), 669 deletions(-) create mode 100644 mobile/lib/widgets/dashboard/class_selection_dialog.dart create mode 100644 src/components/attendance/SelectClassDialog.tsx create mode 100644 supabase/migrations/20260523000001_placeholder.sql create mode 100644 supabase/migrations/20260523000002_placeholder.sql create mode 100644 supabase/migrations/20260523000003_classes_sem_scoped_identity.sql create mode 100644 supabase/migrations/20260523000004_drop.sql create mode 100644 supabase/migrations/20260523000005_drop_classes_end_year.sql create mode 100644 supabase/migrations/20260523000006_unique_classes_lookup.sql diff --git a/mobile/lib/logic/attendance_utils.dart b/mobile/lib/logic/attendance_utils.dart index 41c6b341..6e0862b6 100644 --- a/mobile/lib/logic/attendance_utils.dart +++ b/mobile/lib/logic/attendance_utils.dart @@ -376,6 +376,12 @@ bool isValidPersonName(String text) { return RegExp(r"^[\p{L}\p{M}.'’\- ]+$", unicode: true).hasMatch(trimmed); } +bool isValidCourseName(String text) { + final trimmed = text.trim(); + if (trimmed.isEmpty) return false; + return RegExp(r"^[\p{L}\p{M}\p{N}.'’&/()+,:;\- ]+$", unicode: true).hasMatch(trimmed); +} + String standardizeCourseCode(String input) { return input.trim().toUpperCase().replaceAll(RegExp(r'[\s\u00A0-]'), ''); } diff --git a/mobile/lib/providers/academic_provider.dart b/mobile/lib/providers/academic_provider.dart index 2ae302e9..404e83e7 100644 --- a/mobile/lib/providers/academic_provider.dart +++ b/mobile/lib/providers/academic_provider.dart @@ -82,47 +82,14 @@ final academicProvider = /// Manages the current academic context state, synchronizing with /// secure storage, user settings, and the EzyGo portal. class AcademicNotifier extends AsyncNotifier { - @override - FutureOr build() async { - // Read authProvider to see if we have a logged-in user. - // Do NOT watch it to avoid circular dependency loops with authProvider's initialization. - final auth = ref.read(authProvider).value; - if (auth == null) return null; - + Future _fetchFreshAcademicState() async { + final api = ref.read(apiServiceProvider); final storage = ref.read(secureStorageProvider); - // 1. Primary source: secure storage (written by auth startup flow) - final cached = await storage.getAcademicState(); - if (cached != null) return cached; - - // 2. Fallback: User settings from profile sync - final semester = auth.settings.semester; - final year = auth.settings.academicYear; - - if (semester != null && year != null) { - final state = AcademicState(semester: semester, year: year); - AppLogger.safeUnawait( - storage.saveAcademicState(state).catchError((Object e, StackTrace st) { - AppLogger.e('AcademicNotifier: saveAcademicState failed', e, st); - }), - 'AcademicNotifier: saveAcademicState', - ); - return state; - } - - // 3. Last resort: Calculate from current date - final fallback = calculateCurrentAcademicInfo(); - return AcademicState( - semester: fallback['current_semester']!, - year: fallback['current_year']!, - ); - } - - Future refreshFromEzygo() async { - state = const AsyncValue.loading(); - state = await AsyncValue.guard(() async { - final api = ref.read(apiServiceProvider); - final storage = ref.read(secureStorageProvider); + try { + // Always clear the local fetch cache before reading semester/year so app open + // reflects the current EzyGo defaults instead of a previous session. + api.clearCaches(); final results = await Future.wait>([ api.fetchSemester(storage), @@ -159,8 +126,75 @@ class AcademicNotifier extends AsyncNotifier { return next; } + AppLogger.e( + 'AcademicNotifier: Live EzyGo academic fetch returned incomplete data. Falling back to local state.', + ); + } on Object catch (e, st) { + AppLogger.e('AcademicNotifier: Live EzyGo academic fetch failed', e, st); + } + + return null; + } + + @override + FutureOr build() async { + // Read authProvider to see if we have a logged-in user. + // Do NOT watch it to avoid circular dependency loops with authProvider's initialization. + final auth = ref.read(authProvider).value; + if (auth == null) return null; + + final storage = ref.read(secureStorageProvider); + + // 1. Primary source: the academic context already seeded by auth/profile sync. + final seededSemester = auth.profile?.currentSemester ?? auth.settings.semester; + final seededYear = auth.profile?.currentYear ?? auth.settings.academicYear; + + if (seededSemester != null && seededYear != null) { + final seeded = AcademicState(semester: seededSemester, year: seededYear); + AppLogger.safeUnawait( + storage.saveAcademicState(seeded).catchError((Object e, StackTrace st) { + AppLogger.e('AcademicNotifier: saveAcademicState failed', e, st); + }), + 'AcademicNotifier: saveAcademicState', + ); + return seeded; + } + + // 2. Fallback: secure storage (written by auth startup flow) + final cached = await storage.getAcademicState(); + if (cached != null) return cached; + + // 3. Last resort: Calculate from current date + final fallback = calculateCurrentAcademicInfo(); + return AcademicState( + semester: fallback['current_semester']!, + year: fallback['current_year']!, + ); + } + + Future refreshFromEzygo() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final storage = ref.read(secureStorageProvider); + + final fresh = await _fetchFreshAcademicState(); + if (fresh != null) return fresh; + final cached = await storage.getAcademicState(); - return cached ?? state.value; + if (cached != null) return cached; + + final auth = ref.read(authProvider).value; + final semester = auth?.settings.semester; + final year = auth?.settings.academicYear; + if (semester != null && year != null) { + return AcademicState(semester: semester, year: year); + } + + final fallback = calculateCurrentAcademicInfo(); + return AcademicState( + semester: fallback['current_semester']!, + year: fallback['current_year']!, + ); }); } @@ -200,6 +234,18 @@ class AcademicNotifier extends AsyncNotifier { ref.invalidateSelf(); } } + + Future setAcademicPeriod(String semester, String year) async { + state = const AsyncValue.loading(); + + try { + await ref + .read(authProvider.notifier) + .updateAcademicContext(semester, year); + } finally { + ref.invalidateSelf(); + } + } } (int, int) _parseAcademicYear(String year) { diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index e7ba0092..07e21d4c 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -12,11 +12,7 @@ import 'package:ghostclass/logic/error_utils.dart'; import 'package:ghostclass/models/institution.dart'; import 'package:ghostclass/models/user.dart'; import 'package:ghostclass/providers/academic_provider.dart'; -import 'package:ghostclass/providers/dashboard_provider.dart'; -import 'package:ghostclass/providers/leave_provider.dart'; -import 'package:ghostclass/providers/score_provider.dart'; import 'package:ghostclass/providers/security_provider.dart'; -import 'package:ghostclass/providers/tracking_provider.dart'; import 'package:ghostclass/services/analytics_service.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/cache_manager.dart'; @@ -436,11 +432,15 @@ class AuthNotifier extends AsyncNotifier await logout(force: true); } - Future refreshProfile({bool force = false}) async { + Future refreshProfile({ + bool force = false, + }) async { final inFlight = _refreshProfileInFlight; if (inFlight != null) return inFlight; - final future = _refreshProfileInternal(force: force); + final future = _refreshProfileInternal( + force: force, + ); _refreshProfileInFlight = future; return future.whenComplete(() { if (identical(_refreshProfileInFlight, future)) { @@ -449,7 +449,9 @@ class AuthNotifier extends AsyncNotifier }); } - Future _refreshProfileInternal({bool force = false}) async { + Future _refreshProfileInternal({ + bool force = false, + }) async { final currentUser = state.value; if (currentUser == null) return; @@ -993,25 +995,27 @@ class AuthNotifier extends AsyncNotifier try { // 1. Inform Ezygo of the change first (matches web parity) - final updates = >[]; - if (sem != null) updates.add(api.updateSemester(sem, storage)); - if (year != null) updates.add(api.updateAcademicYear(year, storage)); - - if (updates.isNotEmpty) { - final results = await Future.wait(updates); - for (final r in results) { - final res = r as Response; - if (res.statusCode != 200 && res.statusCode != 201) { - final resData = res.data as Map?; - throw Exception(formatApiError(resData, 'Auth.AcademicUpdate')); - } + if (year != null) { + final yearResponse = await api.updateAcademicYear(year, storage); + if (yearResponse.statusCode != 200 && yearResponse.statusCode != 201) { + final resData = yearResponse.data as Map?; + throw Exception(formatApiError(resData, 'Auth.AcademicUpdate')); + } + } + + if (sem != null) { + final semesterResponse = await api.updateSemester(sem, storage); + if (semesterResponse.statusCode != 200 && semesterResponse.statusCode != 201) { + final resData = semesterResponse.data as Map?; + throw Exception(formatApiError(resData, 'Auth.AcademicUpdate')); } } // 2. Update the dedicated academic state in storage immediately so providers see it + AcademicState? nextAcademic; if (sem != null || year != null) { final currentAcademic = await storage.getAcademicState(); - final nextAcademic = AcademicState( + nextAcademic = AcademicState( semester: sem ?? currentAcademic?.semester ?? @@ -1196,7 +1200,11 @@ class AuthNotifier extends AsyncNotifier } final api = ref.read(apiServiceProvider); - final response = await api.refreshProfile(token, sync: sync, force: force); + final response = await api.refreshProfile( + token, + sync: sync, + force: force, + ); if (response.statusCode == 401) { final data = response.data as Map?; @@ -1414,35 +1422,6 @@ class AuthNotifier extends AsyncNotifier if (academicChanged) { ref.read(apiServiceProvider).clearCaches(); - try { - final api = ref.read(apiServiceProvider); - await Future.wait([ - api.updateSemester(nextAcademic.semester, storage), - api.updateAcademicYear(nextAcademic.year, storage), - ]); - AppLogger.i( - 'AuthNotifier: Successfully synced EzyGo session state to new academic context: ${nextAcademic.semester} ${nextAcademic.year}', - ); - } on Object catch (e) { - AppLogger.e( - 'AuthNotifier: Failed to update EzyGo active semester during self-heal', - e, - ); - } - - // Clear all page caches/providers to force clean re-fetch of all pages - AppLogger.safeUnawait( - Future.microtask(() { - ref - ..invalidate(dashboardProvider) - ..invalidate(trackingProvider) - ..invalidate(scoreProvider) - ..invalidate(leaveProvider); - }).catchError((Object e, StackTrace st) { - AppLogger.e('AuthNotifier: Background invalidation failed', e, st); - }), - 'AuthNotifier: background invalidation', - ); } if (nextAcademic != null) { diff --git a/mobile/lib/providers/dashboard_provider.dart b/mobile/lib/providers/dashboard_provider.dart index 46edcca0..95e75160 100644 --- a/mobile/lib/providers/dashboard_provider.dart +++ b/mobile/lib/providers/dashboard_provider.dart @@ -588,6 +588,21 @@ class DashboardNotifier extends AsyncNotifier { await future; } + Future refreshAfterCourseAdded() async { + final user = ref.read(authProvider).value; + final academic = ref.read(academicProvider).value; + if (user != null && academic != null) { + final storage = ref.read(secureStorageProvider); + final suffix = + '${user.supabaseUserId}_${academic.semester}_${academic.year}'; + await storage.deleteCachedData('dashboard_courses_$suffix'); + } + _cachedCourses = null; + _needsRevalidate = true; + ref.invalidateSelf(); + await future; + } + Future updateLocalInstructor( String courseCode, String instructorName, diff --git a/mobile/lib/screens/dashboard_screen.dart b/mobile/lib/screens/dashboard_screen.dart index 51ed6ba4..53b1551c 100644 --- a/mobile/lib/screens/dashboard_screen.dart +++ b/mobile/lib/screens/dashboard_screen.dart @@ -4,6 +4,7 @@ import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/providers/dashboard_provider.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/logger.dart'; +import 'package:ghostclass/widgets/dashboard/class_selection_dialog.dart'; import 'package:ghostclass/widgets/dashboard/course_list_section.dart'; import 'package:ghostclass/widgets/dashboard/header_section.dart'; import 'package:ghostclass/widgets/dashboard/progress_section.dart'; @@ -21,13 +22,36 @@ class DashboardScreen extends ConsumerStatefulWidget { } class _DashboardScreenState extends ConsumerState { + bool _isDialogOpen = false; + + void _checkAndShowClassDialog() { + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted || _isDialogOpen) return; + final user = ref.read(authProvider).value; + if (user == null) return; + final profile = user.profile; + final isSyncing = user.isSyncing; + + if (!isSyncing && (profile?.classField?.id == null || profile!.classField!.id.isEmpty)) { + setState(() => _isDialogOpen = true); + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const ClassSelectionDialog(), + ); + if (mounted) { + setState(() => _isDialogOpen = false); + } + } + }); + } + @override Widget build(BuildContext context) { final dashboardState = ref.watch(dashboardProvider); final data = dashboardState.value; - final isSyncing = ref.watch( - authProvider.select((v) => v.value?.isSyncing ?? false), - ); + final user = ref.watch(authProvider).value; + final isSyncing = user?.isSyncing ?? false; if (dashboardState.isLoading || isSyncing) { return Scaffold( @@ -58,6 +82,10 @@ class _DashboardScreenState extends ConsumerState { ); } + if (user != null && !isSyncing) { + _checkAndShowClassDialog(); + } + return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: _DashboardContent(data: data), diff --git a/mobile/lib/services/api_service.dart b/mobile/lib/services/api_service.dart index 0b0c764f..cec094bf 100644 --- a/mobile/lib/services/api_service.dart +++ b/mobile/lib/services/api_service.dart @@ -61,7 +61,11 @@ class ApiService { String supabaseToken, { bool sync = false, bool force = false, - }) => _auth.refreshProfile(supabaseToken, sync: sync, force: force); + }) => _auth.refreshProfile( + supabaseToken, + sync: sync, + force: force, + ); Future> syncMobileAuth(String supabaseToken) => _auth.syncMobileAuth(supabaseToken); diff --git a/mobile/lib/widgets/add_attendance_dialog.dart b/mobile/lib/widgets/add_attendance_dialog.dart index d7957a8e..0f7f354d 100644 --- a/mobile/lib/widgets/add_attendance_dialog.dart +++ b/mobile/lib/widgets/add_attendance_dialog.dart @@ -463,13 +463,60 @@ class _AddAttendanceDialogState extends ConsumerState { } Widget _buildSubjectSelectorButton(DashboardData? data, Color primary) { - final selectedCourse = data?.courses.firstWhere( + final profile = ref.watch(authProvider).value?.profile; + final hasNoClass = profile?.classField?.id == null || profile!.classField!.id.isEmpty; + final hasNoCourses = data == null || data.courses.isEmpty; + + if (hasNoClass || hasNoCourses) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: _getUniformFieldColor(), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.12), + ), + ), + child: Row( + children: [ + Icon( + LucideIcons.bookOpen, + size: 18, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'No courses available', + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Icon( + LucideIcons.chevronDown, + size: 16, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.2), + ), + ], + ), + ); + } + + final selectedCourse = data.courses.firstWhere( (c) => c.safeId == _selectedCourseId, orElse: () => const CourseDetails(id: 0, name: 'Select Subject'), ); - final name = selectedCourse?.name ?? 'Select Subject'; - final isDisabled = - selectedCourse != null && _isCourseDisabled(selectedCourse, data); + final name = selectedCourse.name; + final isDisabled = _isCourseDisabled(selectedCourse, data); return InkWell( onTap: () => _showSubjectPickerBottomSheet(data, primary), @@ -771,6 +818,9 @@ class _AddAttendanceDialogState extends ConsumerState { } Widget _buildSubmitButton(Color primary) { + final profile = ref.watch(authProvider).value?.profile; + final hasNoClass = profile?.classField?.id == null || profile!.classField!.id.isEmpty; + return SizedBox( width: double.infinity, height: 52, @@ -779,7 +829,8 @@ class _AddAttendanceDialogState extends ConsumerState { (_isSubmitting || _isBlocked || _selectedSession == null || - _selectedCourseId == null) + _selectedCourseId == null || + hasNoClass) ? null : _handleSubmit, style: ElevatedButton.styleFrom( diff --git a/mobile/lib/widgets/attendance/add_course_dialog.dart b/mobile/lib/widgets/attendance/add_course_dialog.dart index 604df27f..2242bcbf 100644 --- a/mobile/lib/widgets/attendance/add_course_dialog.dart +++ b/mobile/lib/widgets/attendance/add_course_dialog.dart @@ -51,7 +51,7 @@ class _AddCourseDialogState extends ConsumerState { final res = await api.addCourse( courseCode: _codeController.text.trim().toUpperCase(), - courseName: utils.normalizePersonName(_nameController.text), + courseName: _nameController.text.trim().replaceAll(RegExp(r'\s+'), ' '), semester: widget.semester, academicYear: widget.academicYear, supabaseToken: supabaseToken, @@ -70,8 +70,9 @@ class _AddCourseDialogState extends ConsumerState { ); // Refresh dashboard - ref.invalidate(dashboardProvider); + await ref.read(dashboardProvider.notifier).refreshAfterCourseAdded(); + if (!mounted) return; Navigator.pop(context); } on Object catch (e) { if (!mounted) return; @@ -200,8 +201,8 @@ class _AddCourseDialogState extends ConsumerState { final value = v?.trim() ?? ''; if (value.length < 2) return 'Min 2 characters'; if (value.length > 100) return 'Max 100 characters'; - if (!utils.isValidPersonName(value)) { - return 'Name contains invalid characters'; + if (!utils.isValidCourseName(value)) { + return 'Course name contains invalid characters'; } return null; }, diff --git a/mobile/lib/widgets/dashboard/class_selection_dialog.dart b/mobile/lib/widgets/dashboard/class_selection_dialog.dart new file mode 100644 index 00000000..8246b23e --- /dev/null +++ b/mobile/lib/widgets/dashboard/class_selection_dialog.dart @@ -0,0 +1,404 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ghostclass/logic/error_utils.dart'; +import 'package:ghostclass/providers/academic_provider.dart'; +import 'package:ghostclass/providers/auth_provider.dart'; +import 'package:ghostclass/services/api_service.dart'; +import 'package:ghostclass/services/logger.dart'; +import 'package:ghostclass/widgets/service_toast.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class ClassSelectionDialog extends ConsumerStatefulWidget { + const ClassSelectionDialog({super.key}); + + @override + ConsumerState createState() => _ClassSelectionDialogState(); +} + +class _ClassSelectionDialogState extends ConsumerState { + String? _selectedClassId; + String? _selectedClassName; + late final List> _classes = []; + + bool _isLoadingClasses = false; + bool _isSaving = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _fetchClasses()); + } + + Future _fetchClasses() async { + setState(() { + _isLoadingClasses = true; + _errorMessage = null; + }); + + try { + final academic = ref.read(academicProvider).value; + final currentSemester = academic?.semester ?? ''; + final currentYear = academic?.year ?? ''; + + final response = await Supabase.instance.client + .from('classes') + .select('id, name') + .eq('sem', currentSemester) + .eq('year', currentYear) + .order('name', ascending: true); + + final rawList = response as List; + final parsedClasses = rawList + .map((e) => Map.from(e as Map)) + .toList(); + + setState(() { + _classes + ..clear() + ..addAll(parsedClasses); + _isLoadingClasses = false; + if (parsedClasses.isEmpty) { + _errorMessage = + 'No classes found in the database for the current term ($currentSemester $currentYear).'; + } + }); + } on Object catch (e, st) { + AppLogger.e('ClassSelectionDialog: Fetching classes failed', e, st); + setState(() { + _errorMessage = 'Failed to load classes. Please try again.'; + _isLoadingClasses = false; + }); + } + } + + Future _handleConfirm() async { + if (_selectedClassId == null) return; + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + final supabaseToken = Supabase.instance.client.auth.currentSession?.accessToken; + if (supabaseToken == null) { + throw Exception('Session expired. Please log in again.'); + } + + final api = ref.read(apiServiceProvider); + final response = await api.updateProfile(supabaseToken, { + 'class_id': _selectedClassId, + }); + + if (response.statusCode != 200) { + throw Exception(response.statusMessage ?? 'Failed to update profile.'); + } + + await ref.read(authProvider.notifier).syncProfile(); + + if (mounted) { + Navigator.of(context).pop(); + ServiceToast.show(context, 'Class assigned successfully! πŸŽ“'); + } + } on Object catch (e, st) { + AppLogger.e('ClassSelectionDialog: Save failed', e, st); + setState(() { + _errorMessage = formatApiError(e, 'class selection'); + _isSaving = false; + }); + } + } + + Future _showClassPicker() async { + if (_classes.isEmpty) return; + + await showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (ctx) { + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.6, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 12), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + Text( + 'Select Class', + style: GoogleFonts.manrope( + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 12), + Divider( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.05), + ), + Flexible( + child: ListView.builder( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: _classes.length, + itemBuilder: (context, index) { + final item = _classes[index]; + final classId = item['id']?.toString() ?? ''; + final className = item['name']?.toString() ?? 'Unnamed Class'; + final isSelected = classId == _selectedClassId; + + return ListTile( + title: Text( + className, + style: GoogleFonts.manrope( + fontWeight: isSelected ? FontWeight.w800 : FontWeight.w600, + color: isSelected ? Theme.of(context).colorScheme.primary : null, + ), + ), + trailing: isSelected + ? Icon(LucideIcons.check, color: Theme.of(context).colorScheme.primary) + : null, + onTap: () { + setState(() { + _selectedClassId = classId; + _selectedClassName = className; + }); + Navigator.pop(ctx); + }, + ); + }, + ), + ), + const SizedBox(height: 24), + ], + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final surfaceColor = Theme.of(context).colorScheme.surface; + final primaryColor = Theme.of(context).colorScheme.primary; + final onSurfaceColor = Theme.of(context).colorScheme.onSurface; + + return PopScope( + canPop: false, + child: Dialog( + backgroundColor: Colors.transparent, + elevation: 0, + insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: Container( + constraints: const BoxConstraints(maxWidth: 400), + padding: const EdgeInsets.all(28), + decoration: BoxDecoration( + color: surfaceColor, + borderRadius: BorderRadius.circular(32), + border: Border.all( + color: onSurfaceColor.withValues(alpha: 0.08), + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 40, + spreadRadius: 10, + offset: const Offset(0, 20), + ), + ], + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: primaryColor.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon( + LucideIcons.graduationCap, + color: primaryColor, + size: 32, + ), + ), + const SizedBox(height: 24), + Text( + 'Select Your Class', + textAlign: TextAlign.center, + style: GoogleFonts.manrope( + fontSize: 22, + fontWeight: FontWeight.w900, + color: onSurfaceColor, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 12), + Text( + 'Before you can access the dashboard or track attendance, please assign your academic class cohort.', + textAlign: TextAlign.center, + style: GoogleFonts.manrope( + fontSize: 14, + color: onSurfaceColor.withValues(alpha: 0.7), + height: 1.5, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 24), + if (_errorMessage != null) ...[ + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.error.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(LucideIcons.alertCircle, color: Theme.of(context).colorScheme.error, size: 18), + const SizedBox(width: 10), + Expanded( + child: Text( + _errorMessage!, + style: GoogleFonts.manrope( + fontSize: 12, + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + InkWell( + onTap: _isLoadingClasses || _isSaving ? null : _showClassPicker, + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: onSurfaceColor.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: onSurfaceColor.withValues(alpha: 0.08), + ), + ), + child: Row( + children: [ + Icon(LucideIcons.users, size: 18, color: primaryColor), + const SizedBox(width: 12), + Expanded( + child: Text( + _isLoadingClasses + ? 'Loading classes...' + : (_selectedClassName != null ? _selectedClassName! : 'Select Class'), + style: GoogleFonts.manrope( + fontSize: 14, + fontWeight: FontWeight.w700, + color: _selectedClassName != null ? onSurfaceColor : onSurfaceColor.withValues(alpha: 0.4), + ), + ), + ), + if (_isLoadingClasses) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + Icon( + LucideIcons.chevronDown, + size: 16, + color: onSurfaceColor.withValues(alpha: 0.4), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + 'If your class is not listed, please wait until someone else in your class syncs it or until EzyGo is initialized.', + textAlign: TextAlign.center, + style: GoogleFonts.manrope( + fontSize: 12, + color: onSurfaceColor.withValues(alpha: 0.72), + height: 1.4, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: _selectedClassId == null || _isSaving ? null : _handleConfirm, + style: ElevatedButton.styleFrom( + backgroundColor: primaryColor, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + ), + ), + child: _isSaving + ? const CircularProgressIndicator(color: Colors.white) + : Text( + 'Confirm & Proceed', + style: GoogleFonts.manrope( + fontWeight: FontWeight.w800, + fontSize: 16, + ), + ), + ), + ), + const SizedBox(height: 8), + TextButton( + onPressed: _isSaving + ? null + : () async { + await ref.read(authProvider.notifier).logout(); + if (mounted) { + Navigator.of(context).pop(); + } + }, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: Text( + 'Logout', + style: GoogleFonts.manrope( + fontWeight: FontWeight.w700, + fontSize: 14, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/dashboard/course_list_section.dart b/mobile/lib/widgets/dashboard/course_list_section.dart index 32d9e9ab..254c3314 100644 --- a/mobile/lib/widgets/dashboard/course_list_section.dart +++ b/mobile/lib/widgets/dashboard/course_list_section.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ghostclass/logic/attendance_utils.dart' as utils; import 'package:ghostclass/models/course_details.dart'; import 'package:ghostclass/models/course_instructor.dart'; import 'package:ghostclass/models/dashboard_stats.dart'; +import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/widgets/attendance/add_course_dialog.dart'; import 'package:ghostclass/widgets/dashboard/disable_aware_course_card.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -123,7 +125,7 @@ class CourseListSection extends StatelessWidget { } } -class _AddCourseCard extends StatelessWidget { +class _AddCourseCard extends ConsumerWidget { const _AddCourseCard({ required this.semester, required this.academicYear, @@ -134,126 +136,143 @@ class _AddCourseCard extends StatelessWidget { final String? className; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final profile = ref.watch(authProvider).value?.profile; + final hasNoClass = profile?.classField?.id == null || profile!.classField!.id.isEmpty; + return Semantics( button: true, label: 'Add a manual course to your lineup', - child: Container( - margin: const EdgeInsets.only(top: 8), - child: InkWell( - onTap: () => _showAddCourseDialog(context), - borderRadius: BorderRadius.circular(24), - child: Container( - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Theme.of(context).colorScheme.primary.withValues(alpha: 0.08), - Theme.of(context).colorScheme.primary.withValues(alpha: 0.03), - ], - ), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.15), - width: 1.5, - ), - ), - child: Stack( - children: [ - // Decorative Background Circle - Positioned( - right: -20, - top: -20, - child: Container( - width: 120, - height: 120, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - colors: [ - Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.08), - Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0), - ], - ), - ), + child: Opacity( + opacity: hasNoClass ? 0.5 : 1.0, + child: Container( + margin: const EdgeInsets.only(top: 8), + child: InkWell( + onTap: () { + if (hasNoClass) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('You have not assigned a class yet.'), + backgroundColor: Theme.of(context).colorScheme.error, ), + ); + return; + } + _showAddCourseDialog(context); + }, + borderRadius: BorderRadius.circular(24), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Theme.of(context).colorScheme.primary.withValues(alpha: 0.08), + Theme.of(context).colorScheme.primary.withValues(alpha: 0.03), + ], ), - Padding( - padding: const EdgeInsets.all(24), - child: Row( - children: [ - // Icon Container - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.3), - blurRadius: 12, - offset: const Offset(0, 4), - ), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.15), + width: 1.5, + ), + ), + child: Stack( + children: [ + // Decorative Background Circle + Positioned( + right: -20, + top: -20, + child: Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.08), + Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0), ], ), - child: const Icon( - LucideIcons.plusCircle, - color: Colors.white, - size: 32, - ), ), - const SizedBox(width: 20), - // Text Content - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Can't find a course?", - style: GoogleFonts.manrope( - fontSize: 18, - fontWeight: FontWeight.w900, - color: Theme.of(context).colorScheme.onSurface, - letterSpacing: -0.5, - ), - ), - const SizedBox(height: 4), - Text( - 'Add it manually to your lineup\nand start tracking.', - style: GoogleFonts.manrope( - fontSize: 13, - fontWeight: FontWeight.w600, + ), + ), + Padding( + padding: const EdgeInsets.all(24), + child: Row( + children: [ + // Icon Container + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( color: Theme.of( context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - height: 1.3, + ).colorScheme.primary.withValues(alpha: 0.3), + blurRadius: 12, + offset: const Offset(0, 4), ), - ), - ], + ], + ), + child: const Icon( + LucideIcons.plusCircle, + color: Colors.white, + size: 32, + ), ), - ), - Icon( - LucideIcons.chevronRight, - size: 20, - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.4), - ), - ], + const SizedBox(width: 20), + // Text Content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Can't find a course?", + style: GoogleFonts.manrope( + fontSize: 18, + fontWeight: FontWeight.w900, + color: Theme.of(context).colorScheme.onSurface, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 4), + Text( + 'Add it manually to your lineup\nand start tracking.', + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.5), + height: 1.3, + ), + ), + ], + ), + ), + Icon( + LucideIcons.chevronRight, + size: 20, + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.4), + ), + ], + ), ), - ), - ], + ], + ), ), ), ), diff --git a/mobile/lib/widgets/dashboard/header_section.dart b/mobile/lib/widgets/dashboard/header_section.dart index f94c20ca..42fcce38 100644 --- a/mobile/lib/widgets/dashboard/header_section.dart +++ b/mobile/lib/widgets/dashboard/header_section.dart @@ -6,13 +6,29 @@ import 'package:ghostclass/providers/dashboard_provider.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; -class HeaderSection extends ConsumerWidget { +class HeaderSection extends ConsumerStatefulWidget { const HeaderSection({required this.data, super.key}); + final DashboardData data; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _HeaderSectionState(); +} + +class _HeaderSectionState extends ConsumerState { + bool _academicPeriodChangeLocked = false; + + @override + Widget build(BuildContext context) { final profile = ref.watch(authProvider).value?.profile; + final isUpdating = ref.watch(academicProvider).isLoading || _academicPeriodChangeLocked; + + final currentPeriod = _AcademicPeriod( + semester: widget.data.selectedSemester, + year: widget.data.selectedYear, + ); + final previousPeriod = _shiftAcademicPeriod(currentPeriod, _PeriodDirection.previous); + final nextPeriod = _shiftAcademicPeriod(currentPeriod, _PeriodDirection.next); return SliverToBoxAdapter( child: Padding( @@ -20,7 +36,6 @@ class HeaderSection extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 1. Welcome Message RichText( text: TextSpan( style: GoogleFonts.manrope( @@ -42,23 +57,17 @@ class HeaderSection extends ConsumerWidget { ), ), const SizedBox(height: 12), - // 2. Class Badge Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.08), + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(100), border: Border.all( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.15), + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.15), ), ), child: Text( - (profile?.classField?.name ?? data.className ?? 'Unassigned') - .toUpperCase(), + (profile?.classField?.name ?? widget.data.className ?? 'Unassigned').toUpperCase(), style: GoogleFonts.manrope( fontSize: 10, fontWeight: FontWeight.w900, @@ -72,49 +81,31 @@ class HeaderSection extends ConsumerWidget { 'For students juggling classes, internals, labs, submissions, caffeine, and β€œI’ll study tomorrow” energy β˜•πŸ“š', style: GoogleFonts.manrope( fontSize: 12, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), fontWeight: FontWeight.w500, fontStyle: FontStyle.italic, ), ), const SizedBox(height: 20), - - // 3. Selectors - Row( - children: [ - Expanded( - child: Semantics( - label: - 'Current semester: ${data.selectedSemester}. Tap to change.', - button: true, - child: _SelectorButton( - label: data.selectedSemester.toUpperCase(), - icon: LucideIcons.calendar, - onTap: () => _showSemesterPicker( - context, - ref, - data.selectedSemester, - ), - ), - ), + Semantics( + label: 'Current academic period: ${_formatAcademicPeriod(currentPeriod)}. Use the arrows to change it.', + button: true, + child: _AcademicPeriodSwitcher( + currentPeriod: currentPeriod, + previousPeriod: previousPeriod, + nextPeriod: nextPeriod, + isBusy: isUpdating, + onPrevious: () => _requestAcademicPeriodChange( + context, + currentPeriod: currentPeriod, + targetPeriod: previousPeriod, ), - const SizedBox(width: 10), - Expanded( - child: Semantics( - label: - 'Current academic year: ${data.selectedYear}. Tap to change.', - button: true, - child: _SelectorButton( - label: data.selectedYear, - icon: LucideIcons.calendarDays, - onTap: () => - _showYearPicker(context, ref, data.selectedYear), - ), - ), + onNext: () => _requestAcademicPeriodChange( + context, + currentPeriod: currentPeriod, + targetPeriod: nextPeriod, ), - ], + ), ), ], ), @@ -122,248 +113,234 @@ class HeaderSection extends ConsumerWidget { ); } - void _showSemesterPicker( - BuildContext context, - WidgetRef ref, - String current, - ) { - final _ = showModalBottomSheet( - context: context, - useRootNavigator: true, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (context) => _PickerSheet( - title: 'Select Semester', - options: const ['odd', 'even'], - selected: current.toLowerCase(), - onSelected: (val) => _handleAcademicChange( - context, - ref, - type: 'semester', - value: val, - current: current, - ), - ), - ); - } + Future _requestAcademicPeriodChange( + BuildContext context, { + required _AcademicPeriod currentPeriod, + required _AcademicPeriod? targetPeriod, + }) async { + if (_academicPeriodChangeLocked || targetPeriod == null) return; - void _showYearPicker(BuildContext context, WidgetRef ref, String current) { - final currentYear = DateTime.now().year; - final years = List.generate( - (currentYear - 2022) + 1, - (i) => '${2022 + i}-${(2023 + i).toString().substring(2)}', - ); + setState(() { + _academicPeriodChangeLocked = true; + }); - final _ = showModalBottomSheet( + final confirmed = await showDialog( context: context, - useRootNavigator: true, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (context) => _PickerSheet( - title: 'Select Academic Year', - options: years, - selected: current, - onSelected: (val) => _handleAcademicChange( - context, - ref, - type: 'academicYear', - value: val, - current: current, + builder: (dialogContext) => AlertDialog( + title: const Text('Confirm academic period change'), + content: Text( + 'Change from ${_formatAcademicPeriod(currentPeriod)} to ${_formatAcademicPeriod(targetPeriod)}?', ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Confirm'), + ), + ], ), ); - } - Future _handleAcademicChange( - BuildContext context, - WidgetRef ref, { - required String type, - required String value, - required String current, - }) async { - Navigator.pop(context); - if (value == current) return; + if (confirmed != true) { + if (mounted) { + setState(() { + _academicPeriodChangeLocked = false; + }); + } + return; + } - if (type == 'semester') { - await ref.read(academicProvider.notifier).setSemester(value); - } else { - await ref.read(academicProvider.notifier).setYear(value); + try { + await ref.read(academicProvider.notifier).setAcademicPeriod( + targetPeriod.semester, + targetPeriod.year, + ); + } finally { + if (mounted) { + setState(() { + _academicPeriodChangeLocked = false; + }); + } } } } -class _SelectorButton extends StatelessWidget { - const _SelectorButton({ - required this.label, - required this.icon, - required this.onTap, +enum _PeriodDirection { previous, next } + +class _AcademicPeriod { + const _AcademicPeriod({required this.semester, required this.year}); + + final String semester; + final String year; +} + +class _AcademicPeriodSwitcher extends StatelessWidget { + const _AcademicPeriodSwitcher({ + required this.currentPeriod, + required this.previousPeriod, + required this.nextPeriod, + required this.isBusy, + required this.onPrevious, + required this.onNext, }); - final String label; - final IconData icon; - final VoidCallback onTap; + + final _AcademicPeriod currentPeriod; + final _AcademicPeriod? previousPeriod; + final _AcademicPeriod? nextPeriod; + final bool isBusy; + final VoidCallback onPrevious; + final VoidCallback onNext; @override Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.02), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 16, color: Theme.of(context).colorScheme.primary), - const SizedBox(width: 8), - Text( - label, - style: GoogleFonts.manrope( - fontSize: 13, - fontWeight: FontWeight.w800, - color: Theme.of(context).colorScheme.onSurface, + final scheme = Theme.of(context).colorScheme; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: scheme.surface, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: scheme.primary.withValues(alpha: 0.12)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 18, + offset: const Offset(0, 10), + ), + ], + ), + child: Row( + children: [ + _PeriodArrowButton( + icon: LucideIcons.chevronLeft, + label: previousPeriod == null + ? 'Previous academic period unavailable' + : 'Go to ${_formatAcademicPeriod(previousPeriod!)}', + onTap: isBusy || previousPeriod == null ? null : onPrevious, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Academic period', + style: GoogleFonts.manrope( + fontSize: 10, + fontWeight: FontWeight.w900, + letterSpacing: 2, + color: scheme.onSurface.withValues(alpha: 0.55), + ), + ), + const SizedBox(height: 4), + Text( + _formatAcademicPeriod(currentPeriod), + textAlign: TextAlign.center, + style: GoogleFonts.manrope( + fontSize: 16, + fontWeight: FontWeight.w900, + letterSpacing: 0.8, + color: scheme.onSurface, + ), + ), + ], ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(width: 4), - Icon( - LucideIcons.chevronDown, - size: 14, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.3), ), - ], - ), + ), + _PeriodArrowButton( + icon: LucideIcons.chevronRight, + label: nextPeriod == null + ? 'Next academic period unavailable' + : 'Go to ${_formatAcademicPeriod(nextPeriod!)}', + onTap: isBusy || nextPeriod == null ? null : onNext, + ), + ], ), ); } } -class _PickerSheet extends StatelessWidget { - const _PickerSheet({ - required this.title, - required this.options, - required this.selected, - required this.onSelected, +class _PeriodArrowButton extends StatelessWidget { + const _PeriodArrowButton({ + required this.icon, + required this.label, + required this.onTap, }); - final String title; - final List options; - final String selected; - final void Function(String) onSelected; + + final IconData icon; + final String label; + final VoidCallback? onTap; @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(32)), - ), - padding: const EdgeInsets.fromLTRB(24, 12, 24, 40), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Drag Handle - Container( - width: 40, - height: 4, + final scheme = Theme.of(context).colorScheme; + + return Semantics( + button: true, + label: label, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(18), + child: Container( + width: 48, + height: 48, + alignment: Alignment.center, decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(2), + color: onTap == null ? scheme.onSurface.withValues(alpha: 0.04) : scheme.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(18), ), - ), - const SizedBox(height: 24), - Text( - title, - textAlign: TextAlign.center, - style: GoogleFonts.manrope( - fontSize: 20, - fontWeight: FontWeight.w900, - color: Theme.of(context).colorScheme.onSurface, + child: Icon( + icon, + size: 20, + color: onTap == null ? scheme.onSurface.withValues(alpha: 0.28) : scheme.primary, ), ), - const SizedBox(height: 24), - Flexible( - child: Scrollbar( - thumbVisibility: true, - child: SingleChildScrollView( - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.only(right: 8), - child: Column( - children: options.map((opt) { - final isSelected = - opt.toLowerCase() == selected.toLowerCase(); - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: InkWell( - onTap: () => onSelected(opt), - borderRadius: BorderRadius.circular(16), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 16, - ), - decoration: BoxDecoration( - color: isSelected - ? Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.1) - : Colors.transparent, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: isSelected - ? Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.2) - : Colors.transparent, - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - opt.toUpperCase(), - style: GoogleFonts.manrope( - fontSize: 16, - fontWeight: isSelected - ? FontWeight.w800 - : FontWeight.w600, - color: isSelected - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurface, - letterSpacing: 0.5, - ), - ), - if (isSelected) - Icon( - LucideIcons.checkCircle, - size: 20, - color: Theme.of(context).colorScheme.primary, - ), - ], - ), - ), - ), - ); - }).toList(), - ), - ), - ), - ), - ], + ), ), ); } } + +final _academicYearPattern = RegExp(r'^(\d{2}|\d{4})-(\d{2}|\d{4})$'); + +_AcademicPeriod? _shiftAcademicPeriod( + _AcademicPeriod current, + _PeriodDirection direction, +) { + final startYear = _parseAcademicYearStart(current.year); + if (startYear == null) return null; + + if (direction == _PeriodDirection.previous) { + return current.semester.toLowerCase() == 'odd' + ? _AcademicPeriod(semester: 'even', year: _formatAcademicYear(startYear - 1)) + : _AcademicPeriod(semester: 'odd', year: _formatAcademicYear(startYear)); + } + + return current.semester.toLowerCase() == 'odd' + ? _AcademicPeriod(semester: 'even', year: _formatAcademicYear(startYear)) + : _AcademicPeriod(semester: 'odd', year: _formatAcademicYear(startYear + 1)); +} + +int? _parseAcademicYearStart(String year) { + final match = _academicYearPattern.firstMatch(year.trim()); + if (match == null) return null; + + final startRaw = match.group(1)!; + final normalizedStart = startRaw.length == 2 ? '20$startRaw' : startRaw; + return int.tryParse(normalizedStart); +} + +String _formatAcademicYear(int startYear) { + return '$startYear-${(startYear + 1).toString().substring(2)}'; +} + +String _formatAcademicPeriod(_AcademicPeriod period) { + return '${period.semester.toUpperCase()} ${period.year}'; +} diff --git a/mobile/test/providers/academic_provider_test.dart b/mobile/test/providers/academic_provider_test.dart index 387c7f27..66434cac 100644 --- a/mobile/test/providers/academic_provider_test.dart +++ b/mobile/test/providers/academic_provider_test.dart @@ -1,5 +1,18 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:ghostclass/models/user.dart'; import 'package:ghostclass/providers/academic_provider.dart'; +import 'package:ghostclass/providers/auth_provider.dart'; +import 'package:ghostclass/services/api_service.dart'; +import 'package:ghostclass/services/secure_storage.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../coverage_helper.dart'; + +class MockApiService extends Mock implements ApiService {} + +class MockSecureStorageService extends Mock implements SecureStorageService {} void main() { group('AcademicState', () { @@ -30,4 +43,53 @@ void main() { expect(unknown.endDate, DateTime(2026, 6, 30, 23, 59, 59)); }); }); + + group('AcademicNotifier', () { + test('uses auth-seeded academic values on startup without duplicate EzyGo fetches', () async { + final mockApi = MockApiService(); + final mockStorage = MockSecureStorageService(); + final staleAcademic = const AcademicState( + semester: 'even', + year: '2025-2026', + ); + final freshAcademic = const AcademicState( + semester: 'odd', + year: '2025-2026', + ); + final user = createMockUser().copyWith( + profile: const UserProfile( + currentSemester: 'odd', + currentYear: '2025-2026', + ), + settings: UserSettings( + bunkCalculatorEnabled: true, + targetPercentage: 75, + disabledCourses: const {}, + semester: staleAcademic.semester, + academicYear: staleAcademic.year, + ), + ); + + when(() => mockStorage.saveAcademicState(freshAcademic)).thenAnswer((_) async {}); + when(() => mockStorage.getAcademicState()).thenAnswer((_) async => staleAcademic); + + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith(() => MockAuthNotifier(user)), + apiServiceProvider.overrideWithValue(mockApi), + secureStorageProvider.overrideWithValue(mockStorage), + ], + ); + addTearDown(container.dispose); + + final result = await container.read(academicProvider.future); + + expect(result, freshAcademic); + verify(() => mockStorage.saveAcademicState(freshAcademic)).called(1); + verifyNever(() => mockApi.clearCaches()); + verifyNever(() => mockApi.fetchSemester(mockStorage)); + verifyNever(() => mockApi.fetchAcademicYear(mockStorage)); + verifyNever(() => mockStorage.getAcademicState()); + }); + }); } diff --git a/src/app/(protected)/dashboard/DashboardClient.tsx b/src/app/(protected)/dashboard/DashboardClient.tsx index 57847db6..04e43f0f 100644 --- a/src/app/(protected)/dashboard/DashboardClient.tsx +++ b/src/app/(protected)/dashboard/DashboardClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { AnimatePresence, domAnimation, @@ -8,6 +8,7 @@ import { m as motion, } from "framer-motion"; import { toast } from "sonner"; +import { ChevronLeft, ChevronRight } from "lucide-react"; import { logger } from "@/lib/logger"; import { Skeleton } from "@/components/ui/skeleton"; import { @@ -16,12 +17,6 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, -} from "@/components/ui/select"; import { useProfile } from "@/hooks/users/profile"; import { useAllCourseDetails, @@ -54,6 +49,7 @@ import { calculateCurrentAcademicInfo } from "@/lib/logic/academic"; import { AddCourseDialog } from "@/components/attendance/AddCourseDialog"; import { AddAttendanceDialog } from "@/components/attendance/AddAttendanceDialog"; import { EditInstructorDialog } from "@/components/attendance/EditInstructorDialog"; +import { SelectClassDialog } from "@/components/attendance/SelectClassDialog"; import { useFetchCourseInstructors } from "@/hooks/courses/instructors"; import { ClassCourse, useFetchClassCourses } from "@/hooks/courses/useFetchClassCourses"; import { useSyncOnMount } from "@/hooks/use-sync-on-mount"; @@ -101,6 +97,47 @@ type InitialDashboardData = { attendance: unknown; } | null; +type AcademicSemester = "even" | "odd"; + +type AcademicPeriod = { + semester: AcademicSemester; + year: string; +}; + +const academicYearPattern = /^(\d{2}|\d{4})-(\d{2}|\d{4})$/; + +const formatAcademicYear = (startYear: number) => `${startYear}-${String(startYear + 1).slice(-2)}`; + +const parseAcademicYearStart = (year: string) => { + const match = academicYearPattern.exec(year.trim()); + if (!match) return null; + + const startValue = match[1].length === 2 ? `20${match[1]}` : match[1]; + const startYear = Number.parseInt(startValue, 10); + return Number.isNaN(startYear) ? null : startYear; +}; + +const shiftAcademicPeriod = ( + semester: AcademicSemester, + year: string, + direction: "previous" | "next", +): AcademicPeriod | null => { + const startYear = parseAcademicYearStart(year); + if (startYear == null) return null; + + if (direction === "previous") { + return semester === "odd" + ? { semester: "even", year: formatAcademicYear(startYear - 1) } + : { semester: "odd", year: formatAcademicYear(startYear) }; + } + + return semester === "odd" + ? { semester: "even", year: formatAcademicYear(startYear) } + : { semester: "odd", year: formatAcademicYear(startYear + 1) }; +}; + +const formatAcademicPeriod = (period: AcademicPeriod) => `${period.semester.toUpperCase()} ${period.year}`; + interface DashboardClientProps { initialData?: InitialDashboardData; serverError?: string | null; @@ -213,10 +250,12 @@ export default function DashboardClient({ initialData, serverError }: DashboardC const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [isAddCourseOpen, setIsAddCourseOpen] = useState(false); const [isAddAttendanceOpen, setIsAddAttendanceOpen] = useState(false); - const [pendingChange, setPendingChange] = useState<{ type: "semester" | "academicYear"; value: string } | null>(null); + const [pendingChange, setPendingChange] = useState(null); const [isEditInstructorOpen, setIsEditInstructorOpen] = useState(false); const [selectedInstructorCourse, setSelectedInstructorCourse] = useState<{ code: string; name: string; initialName: string } | null>(null); const [isUpdating, setIsUpdating] = useState(false); + const [isSelectClassOpen, setIsSelectClassOpen] = useState(false); + const academicShiftLockRef = useRef(false); const { syncCompleted } = useSyncOnMount({ username: profile?.username, @@ -226,6 +265,14 @@ export default function DashboardClient({ initialData, serverError }: DashboardC sentryTag: "background_sync" }); + useEffect(() => { + if (syncCompleted && profile && !profile.class?.id) { + setIsSelectClassOpen(true); + } else { + setIsSelectClassOpen(false); + } + }, [syncCompleted, profile]); + const { data: rawAttendanceData, isLoading: isLoadingAttendance, refetch: refetchAttendance } = useAttendanceReport(currentSem, currentYear, { enabled: syncCompleted, /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ @@ -280,19 +327,38 @@ export default function DashboardClient({ initialData, serverError }: DashboardC const { disabledCodes } = useDisabledCourses({ academicYear: currentYear, semester: currentSem }); + const pendingPeriodLabel = pendingChange ? formatAcademicPeriod(pendingChange) : null; + + const requestAcademicShift = (direction: "previous" | "next") => { + if (!effectiveSemester || !effectiveYear || isUpdating || academicShiftLockRef.current) return; + + const nextPeriod = shiftAcademicPeriod(effectiveSemester, effectiveYear, direction); + if (!nextPeriod) { + toast.error("Could not compute the next academic period."); + return; + } + + academicShiftLockRef.current = true; + setPendingChange(nextPeriod); + setShowConfirmDialog(true); + }; + const handleConfirmChange = async () => { if (!pendingChange || !profile?.username || isUpdating) return; setIsUpdating(true); setShowConfirmDialog(false); try { - if (pendingChange.type === "semester") { - await setSemesterMutation.mutateAsync({ default_semester: pendingChange.value as "even" | "odd" }); - setSelectedSemester(pendingChange.value as "even" | "odd"); - } else { - await setAcademicYearMutation.mutateAsync({ default_academic_year: pendingChange.value }); - setSelectedYear(pendingChange.value); + if (pendingChange.year !== effectiveYear) { + await setAcademicYearMutation.mutateAsync({ default_academic_year: pendingChange.year }); + } + + if (pendingChange.semester !== effectiveSemester) { + await setSemesterMutation.mutateAsync({ default_semester: pendingChange.semester }); } + + setSelectedSemester(pendingChange.semester); + setSelectedYear(pendingChange.year); queryClient.invalidateQueries({ queryKey: ["profile"] }); } catch (error) { logger.error("Update Failed:", error); @@ -300,16 +366,10 @@ export default function DashboardClient({ initialData, serverError }: DashboardC } finally { setIsUpdating(false); setPendingChange(null); + academicShiftLockRef.current = false; } }; - const academicYears = useMemo(() => { - const current = new Date().getFullYear(); - const years = []; - for (let y = 2022; y <= current; y++) years.push(`${y}-${(y + 1).toString().slice(-2)}`); - return years; - }, []); - const activeCourseCount = useMemo(() => getActiveCourseStats(attendanceData, trackingData || [], coursesData, classCourses || [], disabledCodes, effectiveSemester, effectiveYear, getCourseCode), [attendanceData, trackingData, coursesData, classCourses, disabledCodes, effectiveSemester, effectiveYear, getCourseCode]); const filteredChartData = useMemo(() => { @@ -423,19 +483,36 @@ export default function DashboardClient({ initialData, serverError }: DashboardC

For students juggling classes, internals, labs, submissions, caffeine, and β€œI’ll study tomorrow” energy β˜•πŸ“š

-
-

- Semester: - - Year: - -

+
+ Academic period +
+ +
+
+ Current +
+
+ {effectiveSemester?.toUpperCase()} {effectiveYear} +
+
+ +
@@ -446,7 +523,13 @@ export default function DashboardClient({ initialData, serverError }: DashboardC const customInst = customInstructor as CustomInstructor | undefined; setSelectedInstructorCourse({ code: String(course.code || course.id).toUpperCase().replace(/[\s\u00A0-]/g, ""), name: String(course.name || ""), initialName: hasCustomName ? (customInst?.instructor_name ?? "") : "" }); setIsEditInstructorOpen(true); - }} onAddCourse={() => setIsAddCourseOpen(true)} /> + }} onAddCourse={() => { + if (!profile?.class?.id) { + toast.error("You have not assigned a class yet."); + } else { + setIsAddCourseOpen(true); + } + }} />
@@ -457,16 +540,39 @@ export default function DashboardClient({ initialData, serverError }: DashboardC
- + { + setShowConfirmDialog(open); + if (!open) { + setPendingChange(null); + academicShiftLockRef.current = false; + } + }} + > - Confirm ChangeChange {pendingChange?.type}? - { setShowConfirmDialog(false); setPendingChange(null); }}>CancelConfirm + + Confirm academic period change + + Change from {effectiveSemester?.toUpperCase()} {effectiveYear} to {pendingPeriodLabel}? + + + { setShowConfirmDialog(false); setPendingChange(null); academicShiftLockRef.current = false; }}>CancelConfirm Promise.all([refetchAttendance(), refetchTracking()])} selectedSemester={currentSem} selectedYear={currentYear} /> + {currentSem && currentYear && ( + + )}
diff --git a/src/app/(protected)/dashboard/__tests__/DashboardClient.coverage.test.tsx b/src/app/(protected)/dashboard/__tests__/DashboardClient.coverage.test.tsx index 8dc6c9ff..0079c884 100644 --- a/src/app/(protected)/dashboard/__tests__/DashboardClient.coverage.test.tsx +++ b/src/app/(protected)/dashboard/__tests__/DashboardClient.coverage.test.tsx @@ -108,6 +108,10 @@ vi.mock('@/components/attendance/EditInstructorDialog', () => ({ EditInstructorDialog: ({ open }: any) => (open ?
: null), })); +vi.mock('@/components/attendance/SelectClassDialog', () => ({ + SelectClassDialog: () => null, +})); + vi.mock('next/dynamic', () => ({ default: () => { return function DynamicComponent() { @@ -127,10 +131,10 @@ vi.mock('@/components/ui/select', () => ({ {children} ), - SelectTrigger: ({ children }: any) => , - SelectContent: ({ children }: any) => <>{children}, + SelectTrigger: ({ children }: any) => , + SelectContent: ({ children }: any) =>
{children}
, SelectItem: ({ children, value }: any) => , - SelectValue: ({ children }: any) => <>{children}, + SelectValue: ({ children }: any) => {children}, })); vi.mock('@/components/ui/alert-dialog', () => ({ @@ -162,7 +166,7 @@ vi.mock('@/components/loading', () => ({ })); describe('DashboardClient', () => { - const mockProfile = { id: '123', username: 'testuser', first_name: 'Test' }; + const mockProfile = { id: '123', username: 'testuser', first_name: 'Test', class: { id: '1', name: 'Test Class' } }; beforeEach(() => { vi.useRealTimers(); @@ -191,8 +195,7 @@ describe('DashboardClient', () => { }); expect(screen.getByText(/Welcome back/i)).toBeInTheDocument(); - expect(screen.getByDisplayValue('odd')).toBeInTheDocument(); - expect(screen.getByDisplayValue('2024-25')).toBeInTheDocument(); + expect(screen.getAllByText(/ODD 2024-25/i).length).toBeGreaterThan(0); }); it('handles serverError by showing a toast', () => { @@ -212,10 +215,9 @@ describe('DashboardClient', () => { expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument(); }); - const selects = screen.getAllByTestId('select-component'); - fireEvent.change(selects[0], { target: { value: 'even' } }); + fireEvent.click(screen.getByLabelText('Go to next academic period')); - expect(screen.getByText(/Confirm Change/i)).toBeInTheDocument(); + expect(screen.getByText(/Confirm academic period change/i)).toBeInTheDocument(); }); it('calculates stats correctly', async () => { @@ -260,9 +262,10 @@ describe('DashboardClient', () => { expect(screen.getByText(/No courses found/i)).toBeInTheDocument(); }); - const addBtn = screen.getByText('Add Your First Course'); - fireEvent.click(addBtn); - expect(screen.getByTestId('add-course-dialog')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Add Your First Course')); + await waitFor(() => { + expect(screen.getByTestId('add-course-dialog')).toBeInTheDocument(); + }); }); it('handles confirm and cancel in AlertDialog', async () => { @@ -272,15 +275,14 @@ describe('DashboardClient', () => { render(); await waitFor(() => expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument()); - const selects = screen.getAllByTestId('select-component'); - fireEvent.change(selects[0], { target: { value: 'even' } }); + fireEvent.click(screen.getByLabelText('Go to next academic period')); // Cancel first fireEvent.click(screen.getByText('Cancel')); expect(screen.queryByTestId('alert-dialog')).not.toBeInTheDocument(); // Trigger again and confirm - fireEvent.change(selects[0], { target: { value: 'even' } }); + fireEvent.click(screen.getByLabelText('Go to next academic period')); fireEvent.click(screen.getByText('Confirm')); await waitFor(() => { expect(mockMutate).toHaveBeenCalled(); @@ -322,8 +324,9 @@ describe('DashboardClient', () => { render(); await waitFor(() => expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument()); - const addCard = screen.getByText(/Can't find a course/i); - fireEvent.click(addCard); - expect(screen.getByTestId('add-course-dialog')).toBeInTheDocument(); + fireEvent.click(screen.getByText(/Can't find a course/i)); + await waitFor(() => { + expect(screen.getByTestId('add-course-dialog')).toBeInTheDocument(); + }); }); }); diff --git a/src/app/(protected)/dashboard/__tests__/DashboardClient.test.tsx b/src/app/(protected)/dashboard/__tests__/DashboardClient.test.tsx index f25c0b89..7119932d 100644 --- a/src/app/(protected)/dashboard/__tests__/DashboardClient.test.tsx +++ b/src/app/(protected)/dashboard/__tests__/DashboardClient.test.tsx @@ -31,7 +31,7 @@ type MotionProps = MockComponentProps & { // Mock all hooks vi.mock('@/hooks/users/profile', () => ({ - useProfile: vi.fn(() => ({ data: { first_name: 'Test', last_name: 'User', username: 'testuser', id: 1 }, isLoading: false, isFetching: false, refetch: vi.fn() })), + useProfile: vi.fn(() => ({ data: { first_name: 'Test', last_name: 'User', username: 'testuser', id: 1, class: { id: 1, name: 'Test Class' } }, isLoading: false, isFetching: false, refetch: vi.fn() })), })); vi.mock('@/hooks/courses/attendance', () => ({ @@ -183,6 +183,10 @@ vi.mock('@/components/attendance/EditInstructorDialog', () => ({ EditInstructorDialog: ({ open }: { open?: boolean }) => open ?
: null, })); +vi.mock('@/components/attendance/SelectClassDialog', () => ({ + SelectClassDialog: () => null, +})); + // Mock axios vi.mock('@/lib/axios', () => ({ default: { @@ -216,8 +220,8 @@ describe('DashboardClient', () => { ); - const changeButtons = await screen.findAllByText('Change to EVEN'); - fireEvent.click(changeButtons[0]); + const nextButton = await screen.findByLabelText('Go to next academic period'); + fireEvent.click(nextButton); expect(screen.getByTestId('alert-dialog')).toBeInTheDocument(); diff --git a/src/app/actions/__tests__/courses.test.ts b/src/app/actions/__tests__/courses.test.ts index 82d4ada2..dfd12833 100644 --- a/src/app/actions/__tests__/courses.test.ts +++ b/src/app/actions/__tests__/courses.test.ts @@ -93,7 +93,7 @@ describe("course actions", () => { expect(result).toEqual({}); expect(mockSupabase.insert).toHaveBeenCalledWith(expect.objectContaining({ course_code: "CS101", - course_name: "Intro To Computer Science" + course_name: "intro to computer science" })); expect(revalidatePath).toHaveBeenCalledWith("/dashboard"); }); diff --git a/src/app/actions/courses.ts b/src/app/actions/courses.ts index dd1e8bad..df858e2b 100644 --- a/src/app/actions/courses.ts +++ b/src/app/actions/courses.ts @@ -5,7 +5,7 @@ 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 { academicYearSchema, courseCodeSchema, courseNameSchema, semesterSchema } from "@/lib/validation/text"; export async function addCourseAction(formData: FormData): Promise<{ error?: string }> { const courseCodeValue = formData.get("courseCode"); @@ -25,7 +25,7 @@ export async function addCourseAction(formData: FormData): Promise<{ error?: str // Strict sanitization: Trim all inputs, capitalize and strip spaces from code, title case the name. const parsed = z.object({ courseCode: courseCodeSchema, - courseName: personNameSchema, + courseName: courseNameSchema, semester: semesterSchema, academicYear: academicYearSchema, }).safeParse({ diff --git a/src/app/actions/user.ts b/src/app/actions/user.ts index b5cd27ba..28ab09be 100644 --- a/src/app/actions/user.ts +++ b/src/app/actions/user.ts @@ -123,4 +123,36 @@ export async function clearTermsRedirectCountCookie() { secure: process.env.HTTPS === 'true' || process.env.NODE_ENV === 'production', httpOnly: true, }); +} + +export async function getAvailableClassesAction(semester: string, academicYear: string) { + const supabase = await createClient(); + const { data, error } = await supabase + .from("classes") + .select("id, name") + .eq("sem", semester) + .eq("year", academicYear) + .order("name", { ascending: true }); + + if (error) { + throw new Error(error.message); + } + return data || []; +} + +export async function selectUserClassAction(classId: string | null) { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) throw new Error("Unauthorized"); + + const { error } = await supabase + .from("users") + .update({ class_id: classId }) + .eq("auth_id", user.id); + + if (error) { + throw new Error(error.message); + } + + revalidatePath("/dashboard"); } \ No newline at end of file diff --git a/src/app/api/logout/route.ts b/src/app/api/logout/route.ts index 3445ec3e..8a2742fa 100644 --- a/src/app/api/logout/route.ts +++ b/src/app/api/logout/route.ts @@ -46,9 +46,12 @@ const handler = async (req: NextRequest) => { // would otherwise remain. We derive the name from NEXT_PUBLIC_SUPABASE_URL // which is always available in the server runtime. try { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ""; + const isProd = process.env.NODE_ENV === "production" || process.env.FORCE_PROD_SUPABASE === "true"; + const supabaseUrl = (!isProd && process.env.NEXT_PUBLIC_SUPABASE_DEV_URL) + ? process.env.NEXT_PUBLIC_SUPABASE_DEV_URL + : process.env.NEXT_PUBLIC_SUPABASE_URL; // URL pattern: https://{project-ref}.supabase.co - const projectRef = new URL(supabaseUrl).hostname.split(".")[0]; + const projectRef = new URL(supabaseUrl || "").hostname.split(".")[0]; if (projectRef) { const cookieStore = await cookies(); const cookieName = `sb-${projectRef}-auth-token`; diff --git a/src/app/api/profile/route.ts b/src/app/api/profile/route.ts index 1d88f853..85567046 100644 --- a/src/app/api/profile/route.ts +++ b/src/app/api/profile/route.ts @@ -63,12 +63,14 @@ async function authenticateUser(req: NextRequest, supabaseAdmin: ReturnType { +async function ingestNewProfile( + user: { id: string }, +): Promise { const token = await getAuthTokenWithFallback(user.id); if (!token) return NextResponse.json({ error: "No token" }, { status: 401, headers: { "Cache-Control": "no-store" } }); try { - const syncResult = await performProfileSync(token, "", user.id); + const syncResult = await performProfileSync(token, "", user.id, true); const bundle = await getProfileBundle(user.id, syncResult?.academic); if (!bundle) return NextResponse.json({ error: "Profile not found after ingestion" }, { status: 404 }); return NextResponse.json(bundle); @@ -117,7 +119,7 @@ async function performSyncAndFetchUser( userId: string, existingUser: ExistingUserRawType, isDebounced: boolean, - supabaseAdmin: ReturnType + supabaseAdmin: ReturnType, ): Promise<{ updatedUser: ExistingUserRawType; syncResult: { academic?: { current_semester?: string | null; current_year?: string | null } } | null; @@ -142,7 +144,7 @@ async function loadExistingUserBundle( userId: string, shouldSync: boolean, isDebounced: boolean, - supabaseAdmin: ReturnType + supabaseAdmin: ReturnType, ): Promise { let existingUser = existingUserRaw; let resolvedToken: string | null = null; @@ -171,7 +173,7 @@ async function loadExistingUserBundle( } if (!syncToken) return; try { - await performProfileSync(syncToken, String(existingUser.id), userId); + await performProfileSync(syncToken, String(existingUser.id), userId, true); } catch (err) { logger.warn("Profile background sync failed", err); } @@ -222,7 +224,6 @@ const getHandler = async (req: NextRequest) => { const searchParams = req.nextUrl.searchParams; const shouldSync = searchParams.get("sync") === "true"; const force = searchParams.get("force") === "true"; - if (existingUserRaw && existingUserRaw.first_name) { const lastSyncedAtStr = existingUserRaw.last_synced_at as string | null | undefined; const lastSyncedAt = lastSyncedAtStr ? new Date(lastSyncedAtStr) : new Date(0); @@ -236,15 +237,19 @@ const getHandler = async (req: NextRequest) => { }; const patchSchema = z.object({ - first_name: personNameSchema, - last_name: optionalPersonNameSchema, + first_name: personNameSchema.optional(), + last_name: optionalPersonNameSchema.optional(), gender: genderSchema.optional().nullable(), birth_date: birthDateSchema.optional().nullable(), + class_id: z.string().uuid().optional().nullable(), }); function buildUpdatePayload(parsedData: z.infer) { - const { first_name, last_name, gender, birth_date } = parsedData; - const up: Record = { first_name, last_name }; + const { first_name, last_name, gender, birth_date, class_id } = parsedData; + const up: Record = {}; + if (first_name !== undefined) up.first_name = first_name; + if (last_name !== undefined) up.last_name = last_name; + if (class_id !== undefined) up.class_id = class_id; if (gender !== undefined) { if (gender === null) { up.gender = null; @@ -265,7 +270,7 @@ function buildUpdatePayload(parsedData: z.infer) { up.birth_date_iv = enc.iv; } } - return { up, first_name, last_name, gender, birth_date }; + return { up, first_name, last_name, gender, birth_date, class_id }; } const patchHandler = async (req: NextRequest, { decryptedBody }: { decryptedBody?: unknown }) => { @@ -311,7 +316,7 @@ const patchHandler = async (req: NextRequest, { decryptedBody }: { decryptedBody const parsed = patchSchema.safeParse(body); if (!parsed.success) return NextResponse.json({ error: "Validation failed" }, { status: 422, headers: { "Cache-Control": "no-store" } }); - const { up, first_name, last_name, gender, birth_date } = buildUpdatePayload(parsed.data); + const { up, first_name, last_name, gender, birth_date, class_id } = buildUpdatePayload(parsed.data); const { error: updateError } = await supabaseAdmin.from("users").update(up).eq("auth_id", user.id); if (updateError) { @@ -321,7 +326,7 @@ const patchHandler = async (req: NextRequest, { decryptedBody }: { decryptedBody }); return NextResponse.json({ error: "Failed to update profile" }, { status: 500, headers: { "Cache-Control": "no-store" } }); } - return NextResponse.json({ first_name, last_name, gender, birth_date }); + return NextResponse.json({ first_name, last_name, gender, birth_date, class_id }); }; export const GET = withSecurity(getHandler); diff --git a/src/components/attendance/AddAttendanceDialog.tsx b/src/components/attendance/AddAttendanceDialog.tsx index cfd20571..bb7fb409 100644 --- a/src/components/attendance/AddAttendanceDialog.tsx +++ b/src/components/attendance/AddAttendanceDialog.tsx @@ -66,6 +66,7 @@ import { AttendanceReport, Course, TrackAttendance } from "@/types"; import { useDisabledCourses } from "@/hooks/courses/useDisabledCourses"; import { useFetchClassCourses } from "@/hooks/courses/useFetchClassCourses"; import { useCourseLookup } from "@/hooks/courses/useCourseLookup"; +import { useProfile } from "@/hooks/users/profile"; interface User { id: string | number; @@ -342,6 +343,8 @@ export function AddAttendanceDialog({ selectedSemester, selectedYear, }: AddAttendanceDialogProps) { + const { data: profile } = useProfile(); + // --- STATE --- const [date, setDate] = useState(new Date()); const [session, setSession] = useState(""); @@ -733,6 +736,7 @@ export function AddAttendanceDialog({ + + + + + {classes.map((cls) => ( + + {cls.name} + + ))} + + +
+

+ If your class is not listed, please wait until someone else in your class syncs it or until EzyGo is initialized. +

+
+ + + + + + + + + ); +} diff --git a/src/components/service-error-view.tsx b/src/components/service-error-view.tsx index b90d4069..df2f7939 100644 --- a/src/components/service-error-view.tsx +++ b/src/components/service-error-view.tsx @@ -35,9 +35,17 @@ export function ServiceErrorView({ }; const handleLogout = async () => { + const isProd = process.env.NODE_ENV === "production" || process.env.FORCE_PROD_SUPABASE === "true"; + const supabaseUrl = (!isProd && process.env.NEXT_PUBLIC_SUPABASE_DEV_URL) + ? process.env.NEXT_PUBLIC_SUPABASE_DEV_URL + : process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseKey = (!isProd && process.env.NEXT_PUBLIC_SUPABASE_DEV_PUBLISHABLE_KEY) + ? process.env.NEXT_PUBLIC_SUPABASE_DEV_PUBLISHABLE_KEY + : process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; + const supabase = createBrowserClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + supabaseUrl!, + supabaseKey! ); await supabase.auth.signOut(); router.push("/"); @@ -86,8 +94,8 @@ export function ServiceErrorView({ {/* Actions */}
- {showHome && ( - - -
{ expect(screen.getAllByText(/ODD 2024-25/i).length).toBeGreaterThan(0); }); + it('does not seed the attendance query with initial data', async () => { + render(); + + await waitFor(() => { + expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument(); + }); + + expect(vi.mocked(useAttendanceReport)).toHaveBeenCalledWith( + 'odd', + '2024-25', + expect.not.objectContaining({ initialData: expect.anything() }), + ); + }); + it('handles serverError by showing a toast', () => { render(); expect(toast.error).toHaveBeenCalledWith('Dashboard Pre-fetch Failed', expect.any(Object)); diff --git a/src/app/(protected)/dashboard/components/StatsPanel.tsx b/src/app/(protected)/dashboard/components/StatsPanel.tsx index b37b10a8..4be0a4e0 100644 --- a/src/app/(protected)/dashboard/components/StatsPanel.tsx +++ b/src/app/(protected)/dashboard/components/StatsPanel.tsx @@ -117,7 +117,7 @@ export function StatsPanel({ stats, isLoadingAttendance, targetPercentage }: Sta )} -

+

{isLoadingAttendance ? : ( @@ -150,7 +150,7 @@ export function StatsPanel({ stats, isLoadingAttendance, targetPercentage }: Sta  total )} -

+
diff --git a/src/app/(protected)/dashboard/components/__tests__/StatsPanel.test.tsx b/src/app/(protected)/dashboard/components/__tests__/StatsPanel.test.tsx new file mode 100644 index 00000000..c97b402e --- /dev/null +++ b/src/app/(protected)/dashboard/components/__tests__/StatsPanel.test.tsx @@ -0,0 +1,55 @@ +/** @vitest-environment jsdom */ +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { StatsPanel } from '../StatsPanel'; + +vi.mock('@/components/ui/card', () => ({ + Card: ({ children }: { children?: ReactNode }) =>
{children}
, + CardContent: ({ children }: { children?: ReactNode }) =>
{children}
, + CardHeader: ({ children }: { children?: ReactNode }) =>
{children}
, + CardTitle: ({ children }: { children?: ReactNode }) =>
{children}
, +})); + +vi.mock('@/components/ui/skeleton', () => ({ + Skeleton: () =>
, +})); + +vi.mock('framer-motion', () => { + const mockComponent = ({ children, ...rest }: { children?: ReactNode; [key: string]: unknown }) => { + return
{children}
; + }; + + return { + motion: { + div: mockComponent, + }, + m: { + div: mockComponent, + }, + }; +}); + +describe('StatsPanel', () => { + it('renders the loading footer without paragraph nesting', () => { + const { container } = render( + , + ); + + expect(screen.getAllByTestId('skeleton')).toHaveLength(3); + expect(container.querySelector('p')).toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/app/(protected)/layout.tsx b/src/app/(protected)/layout.tsx index d619c58a..f08db517 100644 --- a/src/app/(protected)/layout.tsx +++ b/src/app/(protected)/layout.tsx @@ -4,15 +4,10 @@ import { Navbar } from "@/components/layout/private-navbar"; import { Footer } from "@/components/layout/footer"; import { useInstitutions } from "@/hooks/users/institutions"; import { useEffect, useState, useRef } from "react"; -import { useRouter } from "next/navigation"; import { Toaster } from "@/components/toaster"; import { LazyMotion, domAnimation, m as motion, useScroll } from "framer-motion"; import { cn } from "@/lib/utils"; import { ErrorBoundary } from "@/components/error-boundary"; -import { createClient } from "@/lib/supabase/client"; -import { handleLogout, isAuthSessionMissingError } from "@/lib/security/auth"; -import { logger } from "@/lib/logger"; -import * as Sentry from "@sentry/nextjs"; import { useCSRFToken } from "@/hooks/use-csrf-token"; import { OutageProvider } from "@/providers/outage-provider"; @@ -21,7 +16,6 @@ export default function ProtectedLayout({ }: { children: React.ReactNode; }) { - const router = useRouter(); // IMPORTANT β€” No loading state is tracked here. // // The server middleware (proxy.ts) already verified the Supabase session @@ -36,7 +30,6 @@ export default function ProtectedLayout({ const lastScrollY = useRef(0); const ticking = useRef(false); const isHiddenRef = useRef(false); - const supabaseRef = useRef(createClient()); // Initialize CSRF token useCSRFToken(); @@ -78,51 +71,15 @@ export default function ProtectedLayout({ useInstitutions(); useEffect(() => { - let active = true; - - const checkUser = async () => { - try { - const { data: { user }, error } = await supabaseRef.current.auth.getUser(); - // Handle auth session missing errors β€” force full logout to clear cookies/storage - if (error && isAuthSessionMissingError(error)) { - active = false; - await handleLogout(); - return; - } - if (error) throw error; - - // No Supabase user means the session is gone β€” force full logout so httpOnly - // cookies (ezygo_access_token, CSRF) and client storage are properly cleared - if (!user) { - active = false; - await handleLogout(); - return; - } - - // At this point, Supabase has confirmed a valid user session. - } catch (err) { - if (!active) return; - // Log the error for debugging, then attempt logout - logger.error("Auth check failed:", err instanceof Error ? err.message : String(err)); - Sentry.captureException(err, { - tags: { type: "client_auth_check_failure", location: "protected/layout" }, - }); - try { - await handleLogout(); - } catch (logoutErr) { - // If logout also fails, force navigation to login page - logger.error("Logout failed after auth check error:", logoutErr instanceof Error ? logoutErr.message : String(logoutErr)); - router.replace("/"); - } - } - }; - - checkUser(); - - return () => { - active = false; - }; - }, [router]); + // No client-side auth gate here. + // + // The server middleware (`proxy.ts`) already validates the Supabase + // session before this layout renders. On local dev, the browser-side + // Supabase client can temporarily report no user immediately after login + // even when the server session is valid, which caused an unwanted logout. + // We keep the client session code out of the critical path and let the + // server remain the source of truth. + }, []); return ( diff --git a/src/app/(protected)/leave-applications/LeaveClient.tsx b/src/app/(protected)/leave-applications/LeaveClient.tsx index ff7762d3..489b98d9 100644 --- a/src/app/(protected)/leave-applications/LeaveClient.tsx +++ b/src/app/(protected)/leave-applications/LeaveClient.tsx @@ -140,14 +140,16 @@ function WorkflowHistoryItem({ approver }: { approver: LeaveApprover }) { const styles = getApproverStyles(approver.action_type); return ( -
- +
+ - {approver.action_by_user?.first_name} {approver.action_by_user?.last_name} + + {approver.action_by_user?.first_name} {approver.action_by_user?.last_name} + -
+
+
Workflow History -
+
{validApprovers.map((approver) => ( ))} @@ -253,18 +255,18 @@ function LeaveCard({ leave, sessions }: { leave: LeaveItem; sessions: LeaveSessi )}`; }, [sessions]); - return ( - - -
+ return ( + + +
{status.label} - + Type: {leave.attendancetype?.name || "Leave"}
@@ -279,24 +281,26 @@ function LeaveCard({ leave, sessions }: { leave: LeaveItem; sessions: LeaveSessi )}
- -
-
+ +
+
Applied On -
+
- {formatDate(leave.created_at || null)} + + {formatDate(leave.created_at || null)} +
-
+
Leave Dates -
+
- + {dateRangeStr}
@@ -312,15 +316,15 @@ function LeaveCard({ leave, sessions }: { leave: LeaveItem; sessions: LeaveSessi {sessions.map((session) => (
S: {session.session?.name} - + {session.course?.name || session.course?.code || "Unknown Course"} @@ -340,16 +344,13 @@ function LeaveCard({ leave, sessions }: { leave: LeaveItem; sessions: LeaveSessi {leave.files.map((file) => (
- + {file.file_name} - + ({formatBytes(file.size_byte || 0)})
diff --git a/src/app/(protected)/scores/ScoresClient.tsx b/src/app/(protected)/scores/ScoresClient.tsx index a6346d56..a331a32d 100644 --- a/src/app/(protected)/scores/ScoresClient.tsx +++ b/src/app/(protected)/scores/ScoresClient.tsx @@ -238,9 +238,9 @@ function ScoreCard({ {/* Course */} -
+
- + {getCourseName(exam)}
@@ -788,12 +788,12 @@ function CourseGroupsSection({ return (
{/* Course heading */} -
+
@@ -554,41 +554,41 @@ export const Navbar = () => { {/* Institution Selector (Tablet/Mobile Only) */} {!institutionsLoading && institutions && institutions.length > 0 && ( -
-
-
-
- - - - - - {institutions.map((inst) => ( - - - {inst.institution.name} - - - ))} - - + + + + + + {institutions.map((inst) => ( + + + {inst.institution.name} + + + ))} + + +
-
- )} + )} { + const errMsg = err instanceof Error ? err.message : String(err); try { return await secondary(props); } catch (err2: unknown) { const err2Msg = err2 instanceof Error ? err2.message : String(err2); const msg = `All providers failed. P: ${errMsg} | S: ${err2Msg}`; logger.error(msg); - Sentry.captureException(new Error(msg), { + Sentry.captureException(err2 instanceof Error ? err2 : new Error(msg), { tags: { type: "email_critical" }, - extra: { to: redact("email", props.to) } + extra: { + to: redact("email", props.to), + primary_error: errMsg, + secondary_error: err2Msg, + } }); return { success: false, provider: sName, error: msg }; } @@ -207,14 +220,14 @@ export async function sendEmail(props: SendEmailProps): Promise return await primary(props); } catch (err: unknown) { const errMsg = err instanceof Error ? err.message : String(err); - logger.warn(`${pName} failed:`, errMsg); + logger.warn(`${pName} failed:`, err instanceof Error ? err : errMsg); Sentry.captureMessage(`Failover: ${pName} failed`, { level: "warning", tags: { provider: pName, location: "sendEmail" }, }); if (secondary) { - return await executeFailover(secondary, props, sName, errMsg); + return await executeFailover(secondary, props, sName, err); } return { success: false, provider: pName, error: errMsg }; } diff --git a/src/lib/ezygo-batch-fetcher.ts b/src/lib/ezygo-batch-fetcher.ts index 1a4d69c1..98cbfe0f 100644 --- a/src/lib/ezygo-batch-fetcher.ts +++ b/src/lib/ezygo-batch-fetcher.ts @@ -379,7 +379,7 @@ export function getRateLimiterStats() { */ export function invalidateEzygoCacheForUser(token: string) { const tokenHash = createHash('sha256').update(token).digest('hex'); - const entries = Array.from(requestCache as unknown as Iterable<[string, Promise]>); + const entries = Array.from(requestCache.entries()); for (const [key] of entries) { if (key.includes(`:${tokenHash}:`)) { diff --git a/src/lib/logic/__tests__/bunk.test.ts b/src/lib/logic/__tests__/bunk.test.ts index 9919a60f..360a265f 100644 --- a/src/lib/logic/__tests__/bunk.test.ts +++ b/src/lib/logic/__tests__/bunk.test.ts @@ -45,7 +45,7 @@ describe("calculateAttendance", () => { it("handles 100% target separately", () => { const result = calculateAttendance(90, 100, 100); - expect(result.requiredToAttend).toBe(Infinity); + expect(result.requiredToAttend).toBe(999); }); it("calculates classes able to bunk when above target", () => { @@ -81,7 +81,7 @@ describe("calculateAttendance", () => { it("should handle 100% target percentage", () => { const result = calculateAttendance(8, 10, 100); - expect(result.requiredToAttend).toBe(Infinity); + expect(result.requiredToAttend).toBe(999); }); it("should identify borderline attendance", () => { diff --git a/src/lib/logic/bunk.ts b/src/lib/logic/bunk.ts index b48c9155..edc0623f 100644 --- a/src/lib/logic/bunk.ts +++ b/src/lib/logic/bunk.ts @@ -26,44 +26,62 @@ export function calculateAttendance( const safeTarget = Number.isFinite(targetPercentage) ? Math.min(100, Math.max(1, targetPercentage)) : 75; - const result: AttendanceResult = { - canBunk: 0, - requiredToAttend: 0, - targetPercentage: safeTarget, - isExact: false, - isBorderline: false, - }; if (total <= 0 || present < 0 || present > total) { - return result; + return { + canBunk: 0, + requiredToAttend: 0, + targetPercentage: safeTarget, + isExact: false, + isBorderline: false, + }; } const currentPercentage = (present / total) * 100; - if (currentPercentage < safeTarget - PERCENTAGE_EPSILON) { + if (Math.abs(currentPercentage - safeTarget) < PERCENTAGE_EPSILON) { + return { + canBunk: 0, + requiredToAttend: 0, + targetPercentage: safeTarget, + isExact: true, + isBorderline: false, + }; + } + + if (currentPercentage < safeTarget) { if (safeTarget >= 100) { - result.requiredToAttend = Infinity; - } else { - const required = Math.ceil( - (safeTarget * total - 100 * present) / (100 - safeTarget) - ); - result.requiredToAttend = Math.max(0, required); - } - } else { - // currentPercentage >= safeTarget (within epsilon) - const bunkableExact = (100 * present - safeTarget * total) / safeTarget; - const bunkable = Math.floor(bunkableExact + PERCENTAGE_EPSILON); - - result.canBunk = Math.max(0, bunkable); - - // "Edge/Borderline" case: Above target but cannot skip a full class yet - if (result.canBunk === 0) { - result.isBorderline = true; - if (Math.abs(currentPercentage - safeTarget) < PERCENTAGE_EPSILON) { - result.isExact = true; - } + // Impossible to reach 100% if missed any class + return { + canBunk: 0, + requiredToAttend: 999, + targetPercentage: safeTarget, + isExact: false, + isBorderline: false, + }; } + const required = Math.ceil( + (safeTarget * total - 100 * present) / (100 - safeTarget) + ); + return { + canBunk: 0, + requiredToAttend: required < 0 ? 0 : required, + targetPercentage: safeTarget, + isExact: false, + isBorderline: false, + }; } - return result; + const bunkableExact = (100 * present - safeTarget * total) / safeTarget; + const bunkable = Math.floor(bunkableExact); + return { + canBunk: bunkable < 0 ? 0 : bunkable, + requiredToAttend: 0, + targetPercentage: safeTarget, + isExact: false, + isBorderline: + bunkableExact > 0 && + bunkableExact < 0.9 && + bunkable === 0, + }; } diff --git a/src/lib/notifications/push.ts b/src/lib/notifications/push.ts index 24f6b4f4..402e1c48 100644 --- a/src/lib/notifications/push.ts +++ b/src/lib/notifications/push.ts @@ -101,7 +101,7 @@ export async function sendPushNotification({ const errorMsg = error instanceof Error ? error.message : "Unknown push dispatch failure"; const safeToken = redact("id", token); - logger.error(`[push] Failed to send notification to token ${safeToken}:`, errorMsg); + logger.error(`[push] Failed to send notification to token ${safeToken}:`, error instanceof Error ? error : errorMsg); const errorCode = getErrorCode(error); const isTerminalTokenError = isTerminalError(errorCode); diff --git a/src/lib/user/__tests__/sync.test.ts b/src/lib/user/__tests__/sync.test.ts index c7117c65..1aa045c0 100644 --- a/src/lib/user/__tests__/sync.test.ts +++ b/src/lib/user/__tests__/sync.test.ts @@ -747,7 +747,7 @@ describe('performProfileSync', () => { })); }); - it('overrides academic context and triggers self-heal when there is a mismatch between EzyGo default settings and courses list cohort', async () => { + it('keeps EzyGo default academic context as source of truth even when course cohort differs', async () => { // 1. Mock EzyGo response returning default settings as "odd" and "2024-25" // and course subgroup with "even" and "2024-25" vi.mocked(egressFetch).mockImplementation(async (url: unknown) => { @@ -792,14 +792,13 @@ describe('performProfileSync', () => { const result = await performProfileSync(mockToken, mockEzygoId, mockAuthId); - // Verify it overrides the returned academic context to "even" - expect(result.academic.current_semester).toBe('even'); + // Verify returned academic context follows EzyGo default settings (source of truth) + expect(result.academic.current_semester).toBe('odd'); expect(result.academic.current_year).toBe('2024-25'); - // Verify it triggered the self-heal update call to user/setting/default_semester setting it to "even" + // Verify no self-heal semester update was triggered const calls = vi.mocked(egressFetch).mock.calls; const postCall = calls.find(c => c[0] === 'user/setting/default_semester' && c[1]?.method === 'POST'); - expect(postCall).toBeDefined(); - expect(JSON.parse(postCall![1]!.body as string)).toEqual({ default_semester: 'even' }); + expect(postCall).toBeUndefined(); }); }); diff --git a/src/lib/user/sync.ts b/src/lib/user/sync.ts index 8afd0b65..5db46aa7 100644 --- a/src/lib/user/sync.ts +++ b/src/lib/user/sync.ts @@ -679,7 +679,7 @@ export async function performProfileSync( // Step 1: Fetch Profile and Academic Context in parallel const [ezygoRes, semRaw, yearRaw] = await fetchAcademicAndProfileData(token, fullSync); - let { ezygoAcademicSemester, ezygoAcademicYear, currentAcademic } = resolveAcademicContext( + const { ezygoAcademicSemester, ezygoAcademicYear, currentAcademic } = resolveAcademicContext( semRaw, yearRaw, ); diff --git a/src/types/lru-cache.d.ts b/src/types/lru-cache.d.ts index 3b07de29..956a380d 100644 --- a/src/types/lru-cache.d.ts +++ b/src/types/lru-cache.d.ts @@ -10,6 +10,7 @@ declare module 'lru-cache' { set(key: K, value: V): void; delete(key: K): boolean; clear(): void; + entries(): IterableIterator<[K, V]>; readonly size: number; } } From d68acd2f7aaf9db11f5444d72b52634b3cbc3577 Mon Sep 17 00:00:00 2001 From: Devanarayanan Date: Fri, 22 May 2026 20:41:05 +0000 Subject: [PATCH 14/15] Remove unwanted repo files --- .vscode/extensions.json | 3 - .vscode/mcp.json | 8 -- .vscode/settings.json | 50 --------- mobile/scratch/check_coverage.js | 82 --------------- mobile/scratch/check_diff_coverage.js | 142 -------------------------- 5 files changed, 285 deletions(-) delete mode 100644 .vscode/extensions.json delete mode 100644 .vscode/mcp.json delete mode 100644 .vscode/settings.json delete mode 100644 mobile/scratch/check_coverage.js delete mode 100644 mobile/scratch/check_diff_coverage.js diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 74baffcc..00000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "recommendations": ["denoland.vscode-deno"] -} diff --git a/.vscode/mcp.json b/.vscode/mcp.json deleted file mode 100644 index 3cdb7b4a..00000000 --- a/.vscode/mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "servers": { - "Sentry": { - "url": "https://mcp.sentry.dev/mcp/devanarayanan/javascript-nextjs", - "type": "http" - } - } -} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 617f77a7..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "deno.enablePaths": [ - "supabase/functions" - ], - "deno.lint": true, - "deno.unstable": [ - "bare-node-builtins", - "byonm", - "sloppy-imports", - "unsafe-proto", - "webgpu", - "broadcast-channel", - "worker-options", - "cron", - "kv", - "ffi", - "fs", - "http", - "net" - ], - "[typescript]": { - "editor.defaultFormatter": "denoland.vscode-deno" - }, - "css.lint.unknownAtRules": "ignore", - "scss.lint.unknownAtRules": "ignore", - "less.lint.unknownAtRules": "ignore", - "markdownlint.ignore": [ - "LICENSE" - ], - "java.configuration.updateBuildConfiguration": "automatic", - "dart.env": { - "PUB_CACHE": "mobile/.pub-cache" - }, - - "chat.tools.terminal.autoApprove": { - "flutter": true - }, - - "explorer.autoReveal": true, - "explorer.incrementalNaming": "smart", - "workbench.editor.restoreViewState": true, - "workbench.startupEditor": "readme", - - "search.exclude": { - "**/Temp": true - }, - "files.watcherExclude": { - "**/Temp/**": true - } -} diff --git a/mobile/scratch/check_coverage.js b/mobile/scratch/check_coverage.js deleted file mode 100644 index c877476a..00000000 --- a/mobile/scratch/check_coverage.js +++ /dev/null @@ -1,82 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const lcovPath = path.join(__dirname, '../coverage/lcov.info'); - -if (!fs.existsSync(lcovPath)) { - console.error("LCOV file not found at " + lcovPath); - process.exit(1); -} - -const content = fs.readFileSync(lcovPath, 'utf8'); - -const targetFiles = [ - 'lib/logic/error_handler.dart', - 'lib/screens/login_screen.dart', - 'lib/screens/navigation_shell.dart', - 'lib/screens/splash_screen.dart', - 'lib/services/api_service.dart', - 'lib/services/push_notification_service.dart', - 'lib/services/security_service.dart', - 'lib/widgets/service_toast.dart' -]; - -const records = {}; -let currentSF = null; - -content.split('\n').forEach(line => { - line = line.trim(); - if (line.startsWith('SF:')) { - const fullPath = line.substring(3); - // Find matching target file - const matched = targetFiles.find(tf => fullPath.endsWith(tf)); - if (matched) { - currentSF = matched; - records[currentSF] = { - lf: 0, - lh: 0, - uncovered: [] - }; - } else { - currentSF = null; - } - } else if (currentSF) { - if (line.startsWith('LF:')) { - records[currentSF].lf = parseInt(line.substring(3), 10); - } else if (line.startsWith('LH:')) { - records[currentSF].lh = parseInt(line.substring(3), 10); - } else if (line.startsWith('DA:')) { - const parts = line.substring(3).split(','); - const lineNum = parseInt(parts[0], 10); - const hitCount = parseInt(parts[1], 10); - if (hitCount === 0) { - records[currentSF].uncovered.push(lineNum); - } - } - } -}); - -console.log("=== FLUTTER COVERAGE REPORT ==="); -let allMet = true; -targetFiles.forEach(file => { - const rec = records[file]; - if (!rec) { - console.log(`❌ ${file}: NO COVERAGE DATA FOUND`); - allMet = false; - return; - } - const pct = rec.lf === 0 ? 100 : (rec.lh / rec.lf) * 100; - console.log(`${pct >= 90 ? 'βœ…' : '❌'} ${file}: ${pct.toFixed(2)}% (${rec.lh}/${rec.lf} lines covered)`); - if (rec.uncovered.length > 0 && pct < 90) { - console.log(` Uncovered lines: ${rec.uncovered.join(', ')}`); - } - if (pct < 90) { - allMet = false; - } -}); - -if (allMet) { - console.log("\nπŸŽ‰ SUCCESS: All modified Flutter files meet the >= 90% coverage threshold!"); -} else { - console.log("\n⚠️ WARNING: Some modified Flutter files do not meet the >= 90% coverage threshold!"); -} diff --git a/mobile/scratch/check_diff_coverage.js b/mobile/scratch/check_diff_coverage.js deleted file mode 100644 index 9a32fff5..00000000 --- a/mobile/scratch/check_diff_coverage.js +++ /dev/null @@ -1,142 +0,0 @@ -const { execSync } = require('child_process'); -const fs = require('fs'); -const path = require('path'); - -// 1. Get modified lines via git diff -function getGitDiffModifiedLines() { - const diffOutput = execSync('git diff -U0', { encoding: 'utf8' }); - const files = {}; - let currentFile = null; - - diffOutput.split('\n').forEach(line => { - if (line.startsWith('+++ b/')) { - currentFile = line.substring(6); - files[currentFile] = []; - } else if (line.startsWith('@@ ') && currentFile) { - // Format: @@ -line,count +line,count @@ or @@ -line +line @@ - const match = line.match(/@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,(\d+))?\s+@@/); - if (match) { - const start = parseInt(match[1], 10); - const count = match[2] ? parseInt(match[2], 10) : 1; - for (let i = 0; i < count; i++) { - files[currentFile].push(start + i); - } - } - } - }); - - return files; -} - -// 2. Parse LCOV file -function parseLcov(lcovPath, projectRoot, prefix = '') { - if (!fs.existsSync(lcovPath)) return {}; - const content = fs.readFileSync(lcovPath, 'utf8'); - const records = {}; - let currentFile = null; - - content.split('\n').forEach(line => { - line = line.trim(); - if (line.startsWith('SF:')) { - let fullPath = line.substring(3); - if (!path.isAbsolute(fullPath)) { - fullPath = path.join(projectRoot, prefix, fullPath); - } - // Make path relative to projectRoot - currentFile = path.relative(projectRoot, fullPath); - records[currentFile] = { hits: {}, total: 0, covered: 0 }; - } else if (currentFile && line.startsWith('DA:')) { - const parts = line.substring(3).split(','); - const lineNum = parseInt(parts[0], 10); - const hitCount = parseInt(parts[1], 10); - records[currentFile].hits[lineNum] = hitCount; - records[currentFile].total++; - if (hitCount > 0) records[currentFile].covered++; - } - }); - - return records; -} - -const diffFiles = getGitDiffModifiedLines(); -const workspaceRoot = '/workspace'; -const mobileRoot = '/workspace/mobile'; - -// Load mobile and backend coverages -const mobileLcov = parseLcov(path.join(mobileRoot, 'coverage/lcov.info'), workspaceRoot, 'mobile'); -// Backend Vitest uses lcov too -const backendLcov = parseLcov(path.join(workspaceRoot, 'coverage/lcov.info'), workspaceRoot, ''); - -console.log("\n=================== DIFF COVERAGE ANALYSIS ==================="); - -let totalDiffLines = 0; -let coveredDiffLines = 0; -let hasUncoveredFiles = false; - -Object.entries(diffFiles).forEach(([file, lines]) => { - // Ignore test files and configuration/work-flows - if ( - file.includes('__tests__') || - file.includes('test/') || - file.endsWith('.json') || - file.endsWith('.yml') || - file.endsWith('.yaml') || - file.endsWith('Dockerfile') || - file.endsWith('.env') || - file.startsWith('.') - ) { - return; - } - - const isMobile = file.startsWith('mobile/'); - const coverageData = isMobile ? mobileLcov[file] : (backendLcov[file] || mobileLcov[file]); - - if (lines.length === 0) return; - - console.log(`\nπŸ“„ File: ${file}`); - console.log(` Modified/Added Lines: ${lines.join(', ')}`); - - if (!coverageData) { - console.log(` ⚠️ No coverage report found for this file.`); - totalDiffLines += lines.length; - hasUncoveredFiles = true; - return; - } - - let fileTotal = 0; - let fileCovered = 0; - const uncovered = []; - - lines.forEach(lineNum => { - const hits = coverageData.hits[lineNum]; - if (hits !== undefined) { - fileTotal++; - totalDiffLines++; - if (hits > 0) { - fileCovered++; - coveredDiffLines++; - } else { - uncovered.push(lineNum); - } - } - }); - - const filePct = fileTotal === 0 ? 100 : (fileCovered / fileTotal) * 100; - console.log(` Diff Line Coverage: ${filePct.toFixed(2)}% (${fileCovered}/${fileTotal} lines)`); - if (uncovered.length > 0) { - console.log(` ❌ Uncovered modified lines: ${uncovered.join(', ')}`); - } -}); - -const overallPct = totalDiffLines === 0 ? 100 : (coveredDiffLines / totalDiffLines) * 100; -console.log("\n=============================================================="); -console.log(`πŸ‘‰ OVERALL DIFF LINE COVERAGE: ${overallPct.toFixed(2)}% (${coveredDiffLines}/${totalDiffLines} lines)`); -console.log("=============================================================="); - -if (overallPct >= 90) { - console.log("πŸŽ‰ SUCCESS: Diff coverage meets or exceeds the required 90% threshold!"); - process.exit(0); -} else { - console.log("❌ FAILURE: Diff coverage is below the required 90% threshold."); - process.exit(1); -} From 7a9f82ae6d8d387a01afbbddc17d162ce7d4e6a3 Mon Sep 17 00:00:00 2001 From: Devanarayanan Date: Fri, 22 May 2026 21:29:22 +0000 Subject: [PATCH 15/15] refactor: clean up code formatting and simplify tests across mobile and web platforms refactor: minor ux changes chore: update deps --- README.md | 22 +- mobile/README.md | 12 +- mobile/lib/logic/attendance_utils.dart | 5 +- mobile/lib/providers/academic_provider.dart | 3 +- mobile/lib/providers/auth_provider.dart | 3 +- mobile/lib/providers/dashboard_provider.dart | 12 +- mobile/lib/router/app_router.dart | 4 +- .../screens/attendance_calendar_screen.dart | 13 +- mobile/lib/screens/dashboard_screen.dart | 6 +- mobile/lib/screens/leaves_screen.dart | 23 +- mobile/lib/screens/notifications_screen.dart | 12 +- mobile/lib/screens/scores_screen.dart | 8 +- mobile/lib/screens/tracking_screen.dart | 7 +- mobile/lib/services/api_service.dart | 8 +- mobile/lib/services/ezygo_service.dart | 4 +- mobile/lib/services/logger.dart | 4 +- mobile/lib/services/refresh_coordinator.dart | 3 +- mobile/lib/services/secure_storage.dart | 10 +- mobile/lib/widgets/add_attendance_dialog.dart | 14 +- .../lib/widgets/calendar/calendar_header.dart | 2 +- .../calendar/calendar_session_card.dart | 26 +- .../dashboard/class_selection_dialog.dart | 66 ++- .../dashboard/course_list_section.dart | 24 +- .../lib/widgets/dashboard/header_section.dart | 60 ++- .../widgets/dashboard/stats_grid_section.dart | 9 +- mobile/pubspec.lock | 16 +- mobile/test/auto_coverage_booster_test.dart | 14 + mobile/test/logic/error_utils_test.dart | 4 +- .../providers/academic_provider_test.dart | 92 ++-- .../push_notification_service_test.dart | 9 +- .../security_service_coverage_test.dart | 15 + .../__tests__/ProtectedLayout.test.tsx | 97 ---- src/app/(protected)/__tests__/layout.test.tsx | 110 +--- .../(protected)/dashboard/DashboardClient.tsx | 11 +- .../__tests__/DashboardClient.basic.test.tsx | 1 + src/app/(public)/build-info/page.tsx | 4 +- src/app/api/auth/save-token/route.ts | 134 +++-- src/app/api/provenance/route.ts | 5 +- .../attendance/SelectClassDialog.tsx | 6 +- .../layout/__tests__/private-navbar.test.tsx | 4 +- .../user/__tests__/login-form.test.tsx | 8 +- src/lib/email.ts | 34 +- src/lib/user/sync.ts | 468 +++++++++++------- workers/package-lock.json | 139 ++++-- workers/package.json | 2 +- 45 files changed, 870 insertions(+), 663 deletions(-) diff --git a/README.md b/README.md index 641a30fe..b34871bd 100644 --- a/README.md +++ b/README.md @@ -12,19 +12,19 @@

- Next.js - React - Tailwind + Next.js + React + Tailwind TypeScript

- Flutter + Flutter Android iOS

- Vitest - Playwright + Vitest + Playwright

@@ -54,14 +54,14 @@ GhostClass is the ultimate academic survival tool for students who want to manag ### Core Framework -- **Next.js 16.1.6** - React 19 with App Router +- **Next.js 16.2.6** - React 19 with App Router - **TypeScript 6.0.3** - Strict mode for type safety -- **Flutter 3.27+** - Cross-platform native mobile application +- **Flutter 3.44.0** - Cross-platform native mobile application - **Node.js** - v24.14.1+ ### Styling & UI -- **Tailwind CSS 4** - Utility-first styling with custom design system +- **Tailwind CSS 4.3.0** - Utility-first styling with custom design system - **Radix UI** - Accessible, unstyled component primitives - **Shadcn UI** - Beautiful pre-styled components - **Framer Motion** - Smooth animations and transitions @@ -141,7 +141,7 @@ For the full mathematical derivation, duty leave limits (5 per course), and pseu - **Node.js** - v24.14.1+ - **npm** - v11.11.0+ -- **Flutter SDK** - 3.27+ +- **Flutter SDK** - 3.44.0 - **Docker** - For containerized deployment (optional) ### Quick Start (Web) @@ -153,7 +153,7 @@ For the full mathematical derivation, duty leave limits (5 per course), and pseu ### Quick Start (Mobile) -1. **Install Flutter**: Ensure Flutter SDK 3.27+ is installed. +1. **Install Flutter**: Ensure Flutter SDK 3.44.0 is installed. 2. **Setup**: Navigate to `mobile/` and run `flutter pub get`. 3. **Secrets**: Copy `app_secrets.dart.example` to `app_secrets.dart` and fill your API keys. 4. **Run**: Connect a device and run `flutter run`. diff --git a/mobile/README.md b/mobile/README.md index a1ef8f77..15f4a5c2 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -1,7 +1,7 @@ # GhostClass Mobile -![Flutter](https://img.shields.io/badge/Flutter-3.27+-02569B?style=for-the-badge&logo=flutter&logoColor=white) -![Dart](https://img.shields.io/badge/Dart-^3.11.4-0175C2?style=for-the-badge&logo=dart&logoColor=white) +![Flutter](https://img.shields.io/badge/Flutter-3.44.0-02569B?style=for-the-badge&logo=flutter&logoColor=white) +![Dart](https://img.shields.io/badge/Dart-3.12.0-0175C2?style=for-the-badge&logo=dart&logoColor=white) ![Android](https://img.shields.io/badge/Android-10+-3DDC84?style=for-the-badge&logo=android&logoColor=black) ![iOS](https://img.shields.io/badge/iOS-13+-000000?style=for-the-badge&logo=apple&logoColor=white) ![License](https://img.shields.io/badge/License-GPL%20v3-blue?style=for-the-badge) @@ -29,8 +29,8 @@ GhostClass Mobile is a secure, zero-trust Flutter application that communicates | Package | Version | Purpose | | :--- | :--- | :--- | -| **Flutter** | 3.27+ | Cross-platform UI framework | -| **Dart** | ^3.11.4 | Language | +| **Flutter** | 3.44.0 | Cross-platform UI framework | +| **Dart** | 3.12.0 | Language | ### State Management @@ -200,8 +200,8 @@ mobile/ ### Prerequisites -- **Flutter SDK** β€” 3.27+ ([install](https://docs.flutter.dev/get-started/install)) -- **Dart SDK** β€” ^3.11.4 (bundled with Flutter) +- **Flutter SDK** β€” 3.44.0 ([install](https://docs.flutter.dev/get-started/install)) +- **Dart SDK** β€” 3.12.0 (bundled with Flutter) - **Android Studio / Xcode** β€” for emulator/simulator - **Firebase CLI** β€” for App Check configuration - **A GhostClass backend** β€” see the [root README](../README.md) for web setup diff --git a/mobile/lib/logic/attendance_utils.dart b/mobile/lib/logic/attendance_utils.dart index 6e0862b6..f73172f6 100644 --- a/mobile/lib/logic/attendance_utils.dart +++ b/mobile/lib/logic/attendance_utils.dart @@ -379,7 +379,10 @@ bool isValidPersonName(String text) { bool isValidCourseName(String text) { final trimmed = text.trim(); if (trimmed.isEmpty) return false; - return RegExp(r"^[\p{L}\p{M}\p{N}.'’&/()+,:;\- ]+$", unicode: true).hasMatch(trimmed); + return RegExp( + r"^[\p{L}\p{M}\p{N}.'’&/()+,:;\- ]+$", + unicode: true, + ).hasMatch(trimmed); } String standardizeCourseCode(String input) { diff --git a/mobile/lib/providers/academic_provider.dart b/mobile/lib/providers/academic_provider.dart index 18e4cd2a..035c5a71 100644 --- a/mobile/lib/providers/academic_provider.dart +++ b/mobile/lib/providers/academic_provider.dart @@ -146,7 +146,8 @@ class AcademicNotifier extends AsyncNotifier { final storage = ref.read(secureStorageProvider); // 1. Primary source: the academic context already seeded by auth/profile sync. - final seededSemester = auth.profile?.currentSemester ?? auth.settings.semester; + final seededSemester = + auth.profile?.currentSemester ?? auth.settings.semester; final seededYear = auth.profile?.currentYear ?? auth.settings.academicYear; if (seededSemester != null && seededYear != null) { diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index 07e21d4c..652d3701 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -1005,7 +1005,8 @@ class AuthNotifier extends AsyncNotifier if (sem != null) { final semesterResponse = await api.updateSemester(sem, storage); - if (semesterResponse.statusCode != 200 && semesterResponse.statusCode != 201) { + if (semesterResponse.statusCode != 200 && + semesterResponse.statusCode != 201) { final resData = semesterResponse.data as Map?; throw Exception(formatApiError(resData, 'Auth.AcademicUpdate')); } diff --git a/mobile/lib/providers/dashboard_provider.dart b/mobile/lib/providers/dashboard_provider.dart index 6b6fec32..51fd7041 100644 --- a/mobile/lib/providers/dashboard_provider.dart +++ b/mobile/lib/providers/dashboard_provider.dart @@ -559,7 +559,8 @@ class DashboardNotifier extends AsyncNotifier { try { await runUnifiedPullToRefresh( logLabel: 'DashboardNotifier', - refreshProfile: () => ref.read(authProvider.notifier).refreshProfile(force: true), + refreshProfile: () => + ref.read(authProvider.notifier).refreshProfile(force: true), syncCron: () async { final supabaseToken = ref .read(supabaseClientProvider) @@ -567,7 +568,9 @@ class DashboardNotifier extends AsyncNotifier { .currentSession ?.accessToken; if (supabaseToken == null) return; - await ref.read(apiServiceProvider).triggerSync(supabaseToken, force: true); + await ref + .read(apiServiceProvider) + .triggerSync(supabaseToken, force: true); }, refreshData: () => ref.read(trackingProvider.notifier).refresh(), ); @@ -605,7 +608,10 @@ class DashboardNotifier extends AsyncNotifier { await future; if (refreshError != null) { - Error.throwWithStackTrace(refreshError, refreshStackTrace ?? StackTrace.current); + Error.throwWithStackTrace( + refreshError, + refreshStackTrace ?? StackTrace.current, + ); } } diff --git a/mobile/lib/router/app_router.dart b/mobile/lib/router/app_router.dart index c1f5050d..fb5cea70 100644 --- a/mobile/lib/router/app_router.dart +++ b/mobile/lib/router/app_router.dart @@ -84,7 +84,9 @@ final routerProvider = Provider((ref) { } }); - final refreshStream = GoRouterRefreshStream(Supabase.instance.client.auth.onAuthStateChange); + final refreshStream = GoRouterRefreshStream( + Supabase.instance.client.auth.onAuthStateChange, + ); ref.onDispose(() { refreshStream.dispose(); authRefreshNotifier.dispose(); diff --git a/mobile/lib/screens/attendance_calendar_screen.dart b/mobile/lib/screens/attendance_calendar_screen.dart index 6784527b..fc57b9f8 100644 --- a/mobile/lib/screens/attendance_calendar_screen.dart +++ b/mobile/lib/screens/attendance_calendar_screen.dart @@ -285,10 +285,11 @@ class _CalendarContent extends ConsumerWidget { final events = _getEventsForDay(selectedDay, disabledCodes, context); - final canMovePrev = - focusedDay.year > startDate.year || focusedDay.month > startDate.month; - final canMoveNext = - focusedDay.year < endDate.year || focusedDay.month < endDate.month; + final prevMonthLastDay = DateTime(focusedDay.year, focusedDay.month, 0); + final canMovePrev = !prevMonthLastDay.isBefore(startDate); + + final nextMonthFirstDay = DateTime(focusedDay.year, focusedDay.month + 1); + final canMoveNext = !nextMonthFirstDay.isAfter(endDate); return ServiceRefreshIndicator( onRefresh: () async { @@ -316,7 +317,7 @@ class _CalendarContent extends ConsumerWidget { } else { ServiceToast.show( context, - 'Reached start of ${dashboard.selectedSemester.toUpperCase()} ${dashboard.selectedYear}', + 'Start of ${dashboard.selectedSemester.toUpperCase()} semester reached.', ); } }, @@ -326,7 +327,7 @@ class _CalendarContent extends ConsumerWidget { } else { ServiceToast.show( context, - 'Reached end of ${dashboard.selectedSemester.toUpperCase()} ${dashboard.selectedYear}', + 'End of ${dashboard.selectedSemester.toUpperCase()} semester reached.', ); } }, diff --git a/mobile/lib/screens/dashboard_screen.dart b/mobile/lib/screens/dashboard_screen.dart index 1daa5fa7..ce91f752 100644 --- a/mobile/lib/screens/dashboard_screen.dart +++ b/mobile/lib/screens/dashboard_screen.dart @@ -32,8 +32,10 @@ class _DashboardScreenState extends ConsumerState { if (user == null) return; final profile = user.profile; final isSyncing = user.isSyncing; - - if (!isSyncing && (profile?.classField?.id == null || profile!.classField!.id.isEmpty)) { + + if (!isSyncing && + (profile?.classField?.id == null || + profile!.classField!.id.isEmpty)) { setState(() => _isDialogOpen = true); await showDialog( context: context, diff --git a/mobile/lib/screens/leaves_screen.dart b/mobile/lib/screens/leaves_screen.dart index c05424ec..a5f47610 100644 --- a/mobile/lib/screens/leaves_screen.dart +++ b/mobile/lib/screens/leaves_screen.dart @@ -77,7 +77,9 @@ class LeavesScreen extends ConsumerWidget { try { await runUnifiedPullToRefresh( logLabel: 'LeavesScreen', - refreshProfile: () => ref.read(authProvider.notifier).refreshProfile(force: true), + refreshProfile: () => ref + .read(authProvider.notifier) + .refreshProfile(force: true), syncCron: () async { final supabaseToken = ref .read(supabaseClientProvider) @@ -85,7 +87,9 @@ class LeavesScreen extends ConsumerWidget { .currentSession ?.accessToken; if (supabaseToken == null) return; - await ref.read(apiServiceProvider).triggerSync(supabaseToken, force: true); + await ref + .read(apiServiceProvider) + .triggerSync(supabaseToken, force: true); }, refreshData: () => ref.read(leaveProvider.notifier).refresh(), ); @@ -124,7 +128,9 @@ class LeavesScreen extends ConsumerWidget { error: err, onRetry: () => runUnifiedPullToRefresh( logLabel: 'LeavesScreen', - refreshProfile: () => ref.read(authProvider.notifier).refreshProfile(force: true), + refreshProfile: () => ref + .read(authProvider.notifier) + .refreshProfile(force: true), syncCron: () async { final supabaseToken = ref .read(supabaseClientProvider) @@ -132,9 +138,12 @@ class LeavesScreen extends ConsumerWidget { .currentSession ?.accessToken; if (supabaseToken == null) return; - await ref.read(apiServiceProvider).triggerSync(supabaseToken, force: true); + await ref + .read(apiServiceProvider) + .triggerSync(supabaseToken, force: true); }, - refreshData: () => ref.read(leaveProvider.notifier).refresh(), + refreshData: () => + ref.read(leaveProvider.notifier).refresh(), ), ), ), @@ -806,7 +815,9 @@ class LeavesScreen extends ConsumerWidget { return _LeaveStatus('Pending', Colors.amber, LucideIcons.clock); } - final hasPending = approvers.any((a) => a.actionType == null && a.actionAt == null); + final hasPending = approvers.any( + (a) => a.actionType == null && a.actionAt == null, + ); final lastAction = actedApprovers.first.actionType; if (hasPending) { diff --git a/mobile/lib/screens/notifications_screen.dart b/mobile/lib/screens/notifications_screen.dart index a7f8cc3f..b7719b61 100644 --- a/mobile/lib/screens/notifications_screen.dart +++ b/mobile/lib/screens/notifications_screen.dart @@ -171,7 +171,9 @@ class _NotificationsScreenState extends ConsumerState try { await runUnifiedPullToRefresh( logLabel: 'NotificationsScreen', - refreshProfile: () => ref.read(authProvider.notifier).refreshProfile(force: true), + refreshProfile: () => ref + .read(authProvider.notifier) + .refreshProfile(force: true), syncCron: () async { final supabaseToken = ref .read(supabaseClientProvider) @@ -179,10 +181,14 @@ class _NotificationsScreenState extends ConsumerState .currentSession ?.accessToken; if (supabaseToken == null) return; - await ref.read(apiServiceProvider).triggerSync(supabaseToken, force: true); + await ref + .read(apiServiceProvider) + .triggerSync(supabaseToken, force: true); }, refreshData: () async { - final _ = await ref.refresh(notificationsProvider.future); + final _ = await ref.refresh( + notificationsProvider.future, + ); }, ); } on Object { diff --git a/mobile/lib/screens/scores_screen.dart b/mobile/lib/screens/scores_screen.dart index 9a493133..86928b65 100644 --- a/mobile/lib/screens/scores_screen.dart +++ b/mobile/lib/screens/scores_screen.dart @@ -78,7 +78,9 @@ class _ScoresScreenState extends ConsumerState { try { await runUnifiedPullToRefresh( logLabel: 'ScoresScreen', - refreshProfile: () => ref.read(authProvider.notifier).refreshProfile(force: true), + refreshProfile: () => ref + .read(authProvider.notifier) + .refreshProfile(force: true), syncCron: () async { final supabaseToken = ref .read(supabaseClientProvider) @@ -86,7 +88,9 @@ class _ScoresScreenState extends ConsumerState { .currentSession ?.accessToken; if (supabaseToken == null) return; - await ref.read(apiServiceProvider).triggerSync(supabaseToken, force: true); + await ref + .read(apiServiceProvider) + .triggerSync(supabaseToken, force: true); }, refreshData: () => ref.read(scoreProvider.notifier).refresh(), ); diff --git a/mobile/lib/screens/tracking_screen.dart b/mobile/lib/screens/tracking_screen.dart index d7101f7f..ce5f90ab 100644 --- a/mobile/lib/screens/tracking_screen.dart +++ b/mobile/lib/screens/tracking_screen.dart @@ -118,7 +118,8 @@ class _TrackingScreenState extends ConsumerState try { await runUnifiedPullToRefresh( logLabel: 'TrackingScreen', - refreshProfile: () => ref.read(authProvider.notifier).refreshProfile(force: true), + refreshProfile: () => + ref.read(authProvider.notifier).refreshProfile(force: true), syncCron: () async { final supabaseToken = ref .read(supabaseClientProvider) @@ -126,7 +127,9 @@ class _TrackingScreenState extends ConsumerState .currentSession ?.accessToken; if (supabaseToken == null) return; - await ref.read(apiServiceProvider).triggerSync(supabaseToken, force: true); + await ref + .read(apiServiceProvider) + .triggerSync(supabaseToken, force: true); }, refreshData: () => ref.read(trackingProvider.notifier).refresh(), ); diff --git a/mobile/lib/services/api_service.dart b/mobile/lib/services/api_service.dart index a5299c62..73f6774a 100644 --- a/mobile/lib/services/api_service.dart +++ b/mobile/lib/services/api_service.dart @@ -62,10 +62,10 @@ class ApiService { bool sync = false, bool force = false, }) => _auth.refreshProfile( - supabaseToken, - sync: sync, - force: force, - ); + supabaseToken, + sync: sync, + force: force, + ); Future> syncMobileAuth(String supabaseToken) => _auth.syncMobileAuth(supabaseToken); diff --git a/mobile/lib/services/ezygo_service.dart b/mobile/lib/services/ezygo_service.dart index ff2e1655..451b6310 100644 --- a/mobile/lib/services/ezygo_service.dart +++ b/mobile/lib/services/ezygo_service.dart @@ -160,7 +160,9 @@ class EzygoService { throw Exception('Status code: ${res.statusCode}'); } } on Object catch (e) { - AppLogger.w('EzygoService: Network fetch failed for $path: $e. No stale-cache fallback is allowed.'); + AppLogger.w( + 'EzygoService: Network fetch failed for $path: $e. No stale-cache fallback is allowed.', + ); rethrow; } } diff --git a/mobile/lib/services/logger.dart b/mobile/lib/services/logger.dart index 88632281..d4afcffd 100644 --- a/mobile/lib/services/logger.dart +++ b/mobile/lib/services/logger.dart @@ -168,7 +168,9 @@ class AppLogger { } final sanitizedMessage = sanitizeForExport(message); - final sanitizedError = error != null ? sanitizeForExport(error.toString()) : null; + final sanitizedError = error != null + ? sanitizeForExport(error.toString()) + : null; final sanitizedExtras = {}; if (extras != null) { for (final e in extras.entries) { diff --git a/mobile/lib/services/refresh_coordinator.dart b/mobile/lib/services/refresh_coordinator.dart index cf929851..601d6f51 100644 --- a/mobile/lib/services/refresh_coordinator.dart +++ b/mobile/lib/services/refresh_coordinator.dart @@ -1,7 +1,6 @@ import 'package:ghostclass/services/logger.dart'; -Future runUnifiedPullToRefresh( - { +Future runUnifiedPullToRefresh({ required Future Function() refreshProfile, required Future Function() refreshData, Future Function()? syncCron, diff --git a/mobile/lib/services/secure_storage.dart b/mobile/lib/services/secure_storage.dart index edf10735..fcf8744d 100644 --- a/mobile/lib/services/secure_storage.dart +++ b/mobile/lib/services/secure_storage.dart @@ -83,7 +83,9 @@ class SecureStorageService { } Future _selfHeal(Object error) async { - AppLogger.e('SecureStorage: Executing self-healing routine due to exception: $error'); + AppLogger.e( + 'SecureStorage: Executing self-healing routine due to exception: $error', + ); try { await _storage.deleteAll(); } on Object catch (e, st) { @@ -130,8 +132,7 @@ class SecureStorageService { Future saveSupabaseUserId(String id) => _safeWrite(key: _Keys.supabaseUserId, value: id); - Future getSupabaseUserId() => - _safeRead(key: _Keys.supabaseUserId); + Future getSupabaseUserId() => _safeRead(key: _Keys.supabaseUserId); // ─── EzyGo User ID & Username ──────────────────────────────────────────── @@ -278,8 +279,7 @@ class SecureStorageService { Future deleteSecure(String key) => _safeDelete(key: key); - Future deleteCachedData(String key) => - _safeDelete(key: 'cache_$key'); + Future deleteCachedData(String key) => _safeDelete(key: 'cache_$key'); // ─── Full Clear ────────────────────────────────────────────────────────── diff --git a/mobile/lib/widgets/add_attendance_dialog.dart b/mobile/lib/widgets/add_attendance_dialog.dart index 0f7f354d..6a72670a 100644 --- a/mobile/lib/widgets/add_attendance_dialog.dart +++ b/mobile/lib/widgets/add_attendance_dialog.dart @@ -464,7 +464,8 @@ class _AddAttendanceDialogState extends ConsumerState { Widget _buildSubjectSelectorButton(DashboardData? data, Color primary) { final profile = ref.watch(authProvider).value?.profile; - final hasNoClass = profile?.classField?.id == null || profile!.classField!.id.isEmpty; + final hasNoClass = + profile?.classField?.id == null || profile!.classField!.id.isEmpty; final hasNoCourses = data == null || data.courses.isEmpty; if (hasNoClass || hasNoCourses) { @@ -484,7 +485,9 @@ class _AddAttendanceDialogState extends ConsumerState { Icon( LucideIcons.bookOpen, size: 18, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.3), ), const SizedBox(width: 12), Expanded( @@ -493,7 +496,9 @@ class _AddAttendanceDialogState extends ConsumerState { style: GoogleFonts.manrope( fontSize: 13, fontWeight: FontWeight.w700, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4), + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.4), ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -819,7 +824,8 @@ class _AddAttendanceDialogState extends ConsumerState { Widget _buildSubmitButton(Color primary) { final profile = ref.watch(authProvider).value?.profile; - final hasNoClass = profile?.classField?.id == null || profile!.classField!.id.isEmpty; + final hasNoClass = + profile?.classField?.id == null || profile!.classField!.id.isEmpty; return SizedBox( width: double.infinity, diff --git a/mobile/lib/widgets/calendar/calendar_header.dart b/mobile/lib/widgets/calendar/calendar_header.dart index 3d943af7..007abf80 100644 --- a/mobile/lib/widgets/calendar/calendar_header.dart +++ b/mobile/lib/widgets/calendar/calendar_header.dart @@ -129,7 +129,7 @@ class _HeaderNavButton extends StatelessWidget { label: icon == LucideIcons.chevronLeft ? 'Previous Month' : 'Next Month', enabled: enabled, child: InkWell( - onTap: enabled ? onTap : null, + onTap: onTap, borderRadius: BorderRadius.circular(12), child: AnimatedOpacity( duration: const Duration(milliseconds: 200), diff --git a/mobile/lib/widgets/calendar/calendar_session_card.dart b/mobile/lib/widgets/calendar/calendar_session_card.dart index e880d14b..1f9e5213 100644 --- a/mobile/lib/widgets/calendar/calendar_session_card.dart +++ b/mobile/lib/widgets/calendar/calendar_session_card.dart @@ -140,11 +140,12 @@ class CalendarSessionCard extends StatelessWidget { style: GoogleFonts.manrope( fontSize: 11, fontWeight: FontWeight.w700, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues( - alpha: isDark ? 0.6 : 0.65, - ), + color: + Theme.of( + context, + ).colorScheme.onSurface.withValues( + alpha: isDark ? 0.6 : 0.65, + ), ), ), ], @@ -217,11 +218,12 @@ class CalendarSessionCard extends StatelessWidget { style: GoogleFonts.manrope( fontSize: 12, fontWeight: FontWeight.w600, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues( - alpha: isDark ? 0.6 : 0.65, - ), + color: + Theme.of( + context, + ).colorScheme.onSurface.withValues( + alpha: isDark ? 0.6 : 0.65, + ), fontStyle: FontStyle.italic, ), ), @@ -425,7 +427,7 @@ Color _getContrastColor(Color color, bool isDark) { if (isDark) { final luminance = color.computeLuminance(); if (luminance >= 0.45) return color; - + final hsl = HSLColor.fromColor(color); var lightness = hsl.lightness; while (lightness < 1.0) { @@ -439,7 +441,7 @@ Color _getContrastColor(Color color, bool isDark) { } else { final luminance = color.computeLuminance(); if (luminance <= 0.18) return color; - + final hsl = HSLColor.fromColor(color); var lightness = hsl.lightness; while (lightness > 0.0) { diff --git a/mobile/lib/widgets/dashboard/class_selection_dialog.dart b/mobile/lib/widgets/dashboard/class_selection_dialog.dart index 8b34486c..9378813e 100644 --- a/mobile/lib/widgets/dashboard/class_selection_dialog.dart +++ b/mobile/lib/widgets/dashboard/class_selection_dialog.dart @@ -14,7 +14,8 @@ class ClassSelectionDialog extends ConsumerStatefulWidget { const ClassSelectionDialog({super.key}); @override - ConsumerState createState() => _ClassSelectionDialogState(); + ConsumerState createState() => + _ClassSelectionDialogState(); } class _ClassSelectionDialogState extends ConsumerState { @@ -83,7 +84,8 @@ class _ClassSelectionDialogState extends ConsumerState { }); try { - final supabaseToken = Supabase.instance.client.auth.currentSession?.accessToken; + final supabaseToken = + Supabase.instance.client.auth.currentSession?.accessToken; if (supabaseToken == null) { throw Exception('Session expired. Please log in again.'); } @@ -136,7 +138,9 @@ class _ClassSelectionDialogState extends ConsumerState { width: 40, height: 4, decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.15), + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2), ), ), @@ -150,29 +154,42 @@ class _ClassSelectionDialogState extends ConsumerState { ), const SizedBox(height: 12), Divider( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.05), + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.05), ), Flexible( child: ListView.builder( shrinkWrap: true, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), itemCount: _classes.length, itemBuilder: (context, index) { final item = _classes[index]; final classId = item['id']?.toString() ?? ''; - final className = item['name']?.toString() ?? 'Unnamed Class'; + final className = + item['name']?.toString() ?? 'Unnamed Class'; final isSelected = classId == _selectedClassId; return ListTile( title: Text( className, style: GoogleFonts.manrope( - fontWeight: isSelected ? FontWeight.w800 : FontWeight.w600, - color: isSelected ? Theme.of(context).colorScheme.primary : null, + fontWeight: isSelected + ? FontWeight.w800 + : FontWeight.w600, + color: isSelected + ? Theme.of(context).colorScheme.primary + : null, ), ), trailing: isSelected - ? Icon(LucideIcons.check, color: Theme.of(context).colorScheme.primary) + ? Icon( + LucideIcons.check, + color: Theme.of(context).colorScheme.primary, + ) : null, onTap: () { setState(() { @@ -268,12 +285,18 @@ class _ClassSelectionDialogState extends ConsumerState { padding: const EdgeInsets.all(12), margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.error.withValues(alpha: 0.1), + color: Theme.of( + context, + ).colorScheme.error.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Row( children: [ - Icon(LucideIcons.alertCircle, color: Theme.of(context).colorScheme.error, size: 18), + Icon( + LucideIcons.alertCircle, + color: Theme.of(context).colorScheme.error, + size: 18, + ), const SizedBox(width: 10), Expanded( child: Text( @@ -290,10 +313,15 @@ class _ClassSelectionDialogState extends ConsumerState { ), ], InkWell( - onTap: _isLoadingClasses || _isSaving ? null : _showClassPicker, + onTap: _isLoadingClasses || _isSaving + ? null + : _showClassPicker, borderRadius: BorderRadius.circular(14), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), decoration: BoxDecoration( color: onSurfaceColor.withValues(alpha: 0.04), borderRadius: BorderRadius.circular(14), @@ -309,11 +337,15 @@ class _ClassSelectionDialogState extends ConsumerState { child: Text( _isLoadingClasses ? 'Loading classes...' - : (_selectedClassName != null ? _selectedClassName! : 'Select Class'), + : (_selectedClassName != null + ? _selectedClassName! + : 'Select Class'), style: GoogleFonts.manrope( fontSize: 14, fontWeight: FontWeight.w700, - color: _selectedClassName != null ? onSurfaceColor : onSurfaceColor.withValues(alpha: 0.4), + color: _selectedClassName != null + ? onSurfaceColor + : onSurfaceColor.withValues(alpha: 0.4), ), ), ), @@ -352,7 +384,9 @@ class _ClassSelectionDialogState extends ConsumerState { width: double.infinity, height: 56, child: ElevatedButton( - onPressed: _selectedClassId == null || _isSaving ? null : _handleConfirm, + onPressed: _selectedClassId == null || _isSaving + ? null + : _handleConfirm, style: ElevatedButton.styleFrom( backgroundColor: primaryColor, foregroundColor: Colors.white, diff --git a/mobile/lib/widgets/dashboard/course_list_section.dart b/mobile/lib/widgets/dashboard/course_list_section.dart index 254c3314..be468fe6 100644 --- a/mobile/lib/widgets/dashboard/course_list_section.dart +++ b/mobile/lib/widgets/dashboard/course_list_section.dart @@ -138,7 +138,8 @@ class _AddCourseCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final profile = ref.watch(authProvider).value?.profile; - final hasNoClass = profile?.classField?.id == null || profile!.classField!.id.isEmpty; + final hasNoClass = + profile?.classField?.id == null || profile!.classField!.id.isEmpty; return Semantics( button: true, @@ -169,8 +170,12 @@ class _AddCourseCard extends ConsumerWidget { begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ - Theme.of(context).colorScheme.primary.withValues(alpha: 0.08), - Theme.of(context).colorScheme.primary.withValues(alpha: 0.03), + Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.08), + Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.03), ], ), border: Border.all( @@ -242,7 +247,9 @@ class _AddCourseCard extends ConsumerWidget { style: GoogleFonts.manrope( fontSize: 18, fontWeight: FontWeight.w900, - color: Theme.of(context).colorScheme.onSurface, + color: Theme.of( + context, + ).colorScheme.onSurface, letterSpacing: -0.5, ), ), @@ -252,9 +259,12 @@ class _AddCourseCard extends ConsumerWidget { style: GoogleFonts.manrope( fontSize: 13, fontWeight: FontWeight.w600, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), + color: + Theme.of( + context, + ).colorScheme.onSurface.withValues( + alpha: 0.5, + ), height: 1.3, ), ), diff --git a/mobile/lib/widgets/dashboard/header_section.dart b/mobile/lib/widgets/dashboard/header_section.dart index 42fcce38..5d913fee 100644 --- a/mobile/lib/widgets/dashboard/header_section.dart +++ b/mobile/lib/widgets/dashboard/header_section.dart @@ -21,14 +21,21 @@ class _HeaderSectionState extends ConsumerState { @override Widget build(BuildContext context) { final profile = ref.watch(authProvider).value?.profile; - final isUpdating = ref.watch(academicProvider).isLoading || _academicPeriodChangeLocked; + final isUpdating = + ref.watch(academicProvider).isLoading || _academicPeriodChangeLocked; final currentPeriod = _AcademicPeriod( semester: widget.data.selectedSemester, year: widget.data.selectedYear, ); - final previousPeriod = _shiftAcademicPeriod(currentPeriod, _PeriodDirection.previous); - final nextPeriod = _shiftAcademicPeriod(currentPeriod, _PeriodDirection.next); + final previousPeriod = _shiftAcademicPeriod( + currentPeriod, + _PeriodDirection.previous, + ); + final nextPeriod = _shiftAcademicPeriod( + currentPeriod, + _PeriodDirection.next, + ); return SliverToBoxAdapter( child: Padding( @@ -60,14 +67,21 @@ class _HeaderSectionState extends ConsumerState { Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.08), + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(100), border: Border.all( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.15), + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.15), ), ), child: Text( - (profile?.classField?.name ?? widget.data.className ?? 'Unassigned').toUpperCase(), + (profile?.classField?.name ?? + widget.data.className ?? + 'Unassigned') + .toUpperCase(), style: GoogleFonts.manrope( fontSize: 10, fontWeight: FontWeight.w900, @@ -81,14 +95,17 @@ class _HeaderSectionState extends ConsumerState { 'For students juggling classes, internals, labs, submissions, caffeine, and β€œI’ll study tomorrow” energy β˜•πŸ“š', style: GoogleFonts.manrope( fontSize: 12, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.5), fontWeight: FontWeight.w500, fontStyle: FontStyle.italic, ), ), const SizedBox(height: 20), Semantics( - label: 'Current academic period: ${_formatAcademicPeriod(currentPeriod)}. Use the arrows to change it.', + label: + 'Current academic period: ${_formatAcademicPeriod(currentPeriod)}. Use the arrows to change it.', button: true, child: _AcademicPeriodSwitcher( currentPeriod: currentPeriod, @@ -154,7 +171,9 @@ class _HeaderSectionState extends ConsumerState { } try { - await ref.read(academicProvider.notifier).setAcademicPeriod( + await ref + .read(academicProvider.notifier) + .setAcademicPeriod( targetPeriod.semester, targetPeriod.year, ); @@ -293,13 +312,17 @@ class _PeriodArrowButton extends StatelessWidget { height: 48, alignment: Alignment.center, decoration: BoxDecoration( - color: onTap == null ? scheme.onSurface.withValues(alpha: 0.04) : scheme.primary.withValues(alpha: 0.08), + color: onTap == null + ? scheme.onSurface.withValues(alpha: 0.04) + : scheme.primary.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(18), ), child: Icon( icon, size: 20, - color: onTap == null ? scheme.onSurface.withValues(alpha: 0.28) : scheme.primary, + color: onTap == null + ? scheme.onSurface.withValues(alpha: 0.28) + : scheme.primary, ), ), ), @@ -319,13 +342,22 @@ _AcademicPeriod? _shiftAcademicPeriod( if (direction == _PeriodDirection.previous) { return current.semester.toLowerCase() == 'odd' - ? _AcademicPeriod(semester: 'even', year: _formatAcademicYear(startYear - 1)) - : _AcademicPeriod(semester: 'odd', year: _formatAcademicYear(startYear)); + ? _AcademicPeriod( + semester: 'even', + year: _formatAcademicYear(startYear - 1), + ) + : _AcademicPeriod( + semester: 'odd', + year: _formatAcademicYear(startYear), + ); } return current.semester.toLowerCase() == 'odd' ? _AcademicPeriod(semester: 'even', year: _formatAcademicYear(startYear)) - : _AcademicPeriod(semester: 'odd', year: _formatAcademicYear(startYear + 1)); + : _AcademicPeriod( + semester: 'odd', + year: _formatAcademicYear(startYear + 1), + ); } int? _parseAcademicYearStart(String year) { diff --git a/mobile/lib/widgets/dashboard/stats_grid_section.dart b/mobile/lib/widgets/dashboard/stats_grid_section.dart index 99f303c4..db9848bd 100644 --- a/mobile/lib/widgets/dashboard/stats_grid_section.dart +++ b/mobile/lib/widgets/dashboard/stats_grid_section.dart @@ -209,9 +209,12 @@ class _StatCard extends StatelessWidget { style: GoogleFonts.manrope( fontSize: 14, fontWeight: FontWeight.bold, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.4), + color: + Theme.of( + context, + ).colorScheme.onSurface.withValues( + alpha: 0.4, + ), ), ), ], diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 3fc8ea44..d655893f 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -964,10 +964,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -1561,26 +1561,26 @@ packages: dependency: transitive description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.31.0" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.17" typed_data: dependency: transitive description: diff --git a/mobile/test/auto_coverage_booster_test.dart b/mobile/test/auto_coverage_booster_test.dart index b5eeef42..7533491b 100644 --- a/mobile/test/auto_coverage_booster_test.dart +++ b/mobile/test/auto_coverage_booster_test.dart @@ -79,10 +79,24 @@ import 'package:ghostclass/widgets/tracking/tracking_header_widgets.dart'; import 'package:ghostclass/widgets/tracking/tracking_record_card.dart'; import 'package:ghostclass/widgets/tracking/tracking_subject_picker.dart'; import 'package:ghostclass/widgets/transparency_badge.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; import 'coverage_helper.dart'; void main() { + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + try { + await Supabase.initialize( + url: 'https://example.com', + anonKey: 'anon', + ); + } on Object catch (_) { + // already initialized + } + }); + final mockDashboard = createMockDashboardData(); final mockUser = createMockUser(); final mockTracking = TrackingState( diff --git a/mobile/test/logic/error_utils_test.dart b/mobile/test/logic/error_utils_test.dart index f654bab8..334dfe42 100644 --- a/mobile/test/logic/error_utils_test.dart +++ b/mobile/test/logic/error_utils_test.dart @@ -220,7 +220,9 @@ void main() { test('does not redact URL paths', () { expect( - sanitizeTechnicalDetails('Request failed at https://api.example.com/v1/users/123'), + sanitizeTechnicalDetails( + 'Request failed at https://api.example.com/v1/users/123', + ), 'Request failed at https://api.example.com/v1/users/123', ); }); diff --git a/mobile/test/providers/academic_provider_test.dart b/mobile/test/providers/academic_provider_test.dart index 66434cac..fa36694d 100644 --- a/mobile/test/providers/academic_provider_test.dart +++ b/mobile/test/providers/academic_provider_test.dart @@ -1,4 +1,3 @@ -import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ghostclass/models/user.dart'; @@ -45,51 +44,58 @@ void main() { }); group('AcademicNotifier', () { - test('uses auth-seeded academic values on startup without duplicate EzyGo fetches', () async { - final mockApi = MockApiService(); - final mockStorage = MockSecureStorageService(); - final staleAcademic = const AcademicState( - semester: 'even', - year: '2025-2026', - ); - final freshAcademic = const AcademicState( - semester: 'odd', - year: '2025-2026', - ); - final user = createMockUser().copyWith( - profile: const UserProfile( - currentSemester: 'odd', - currentYear: '2025-2026', - ), - settings: UserSettings( - bunkCalculatorEnabled: true, - targetPercentage: 75, - disabledCourses: const {}, - semester: staleAcademic.semester, - academicYear: staleAcademic.year, - ), - ); + test( + 'uses auth-seeded academic values on startup without duplicate EzyGo fetches', + () async { + final mockApi = MockApiService(); + final mockStorage = MockSecureStorageService(); + const staleAcademic = AcademicState( + semester: 'even', + year: '2025-2026', + ); + const freshAcademic = AcademicState( + semester: 'odd', + year: '2025-2026', + ); + final user = createMockUser().copyWith( + profile: const UserProfile( + currentSemester: 'odd', + currentYear: '2025-2026', + ), + settings: UserSettings( + bunkCalculatorEnabled: true, + targetPercentage: 75, + disabledCourses: const {}, + semester: staleAcademic.semester, + academicYear: staleAcademic.year, + ), + ); - when(() => mockStorage.saveAcademicState(freshAcademic)).thenAnswer((_) async {}); - when(() => mockStorage.getAcademicState()).thenAnswer((_) async => staleAcademic); + when( + () => mockStorage.saveAcademicState(freshAcademic), + ).thenAnswer((_) async {}); + when( + mockStorage.getAcademicState, + ).thenAnswer((_) async => staleAcademic); - final container = ProviderContainer( - overrides: [ - authProvider.overrideWith(() => MockAuthNotifier(user)), - apiServiceProvider.overrideWithValue(mockApi), - secureStorageProvider.overrideWithValue(mockStorage), - ], - ); - addTearDown(container.dispose); + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith(() => MockAuthNotifier(user)), + apiServiceProvider.overrideWithValue(mockApi), + secureStorageProvider.overrideWithValue(mockStorage), + ], + ); + addTearDown(container.dispose); - final result = await container.read(academicProvider.future); + final result = await container.read(academicProvider.future); - expect(result, freshAcademic); - verify(() => mockStorage.saveAcademicState(freshAcademic)).called(1); - verifyNever(() => mockApi.clearCaches()); - verifyNever(() => mockApi.fetchSemester(mockStorage)); - verifyNever(() => mockApi.fetchAcademicYear(mockStorage)); - verifyNever(() => mockStorage.getAcademicState()); - }); + expect(result, freshAcademic); + verify(() => mockStorage.saveAcademicState(freshAcademic)).called(1); + verifyNever(mockApi.clearCaches); + verifyNever(() => mockApi.fetchSemester(mockStorage)); + verifyNever(() => mockApi.fetchAcademicYear(mockStorage)); + verifyNever(mockStorage.getAcademicState); + }, + ); }); } diff --git a/mobile/test/services/push_notification_service_test.dart b/mobile/test/services/push_notification_service_test.dart index 61123d24..8dffdcad 100644 --- a/mobile/test/services/push_notification_service_test.dart +++ b/mobile/test/services/push_notification_service_test.dart @@ -35,6 +35,8 @@ class MockSession extends Mock implements Session {} class MockFirebaseAnalytics extends Mock implements FirebaseAnalytics {} +class MockGoRouter extends Mock implements GoRouter {} + void main() { late MockFirebaseMessaging mockMessaging; late MockNotificationSettings mockSettings; @@ -558,6 +560,7 @@ void main() { tester, ) async { final foregroundMessages = StreamController(); + final mockGoRouter = MockGoRouter(); when( () => mockSettings.authorizationStatus, @@ -589,6 +592,10 @@ void main() { ), ); + when( + () => mockGoRouter.routerDelegate, + ).thenThrow(Exception('RouterDelegate crash')); + await tester.pumpWidget( ProviderScope( overrides: [ @@ -596,7 +603,7 @@ void main() { dioServiceProvider.overrideWithValue(mockDioService), secureStorageProvider.overrideWithValue(mockStorage), supabaseClientProvider.overrideWithValue(mockSupabase), - routerProvider.overrideWith((ref) => throw Exception('Router crash')), + routerProvider.overrideWithValue(mockGoRouter), ], child: const MaterialApp( home: Scaffold(body: SizedBox()), diff --git a/mobile/test/services/security_service_coverage_test.dart b/mobile/test/services/security_service_coverage_test.dart index da380623..8764cd0b 100644 --- a/mobile/test/services/security_service_coverage_test.dart +++ b/mobile/test/services/security_service_coverage_test.dart @@ -380,6 +380,21 @@ void main() { 'cachedAt': DateTime.now().toIso8601String(), }); + final options = RequestOptions(path: '/api/security/attestation'); + when( + () => mockDio.get(any(), options: any(named: 'options')), + ).thenAnswer( + (_) async => Response( + requestOptions: options, + statusCode: 200, + data: { + 'verified': true, + 'latestVersion': AppConfig.appVersion, + 'minVersion': AppConfig.appVersion, + }, + ), + ); + when( () => mockSecureStorage.getAttestationResult(), ).thenAnswer((_) async => cachedJson); diff --git a/src/app/(protected)/__tests__/ProtectedLayout.test.tsx b/src/app/(protected)/__tests__/ProtectedLayout.test.tsx index 6b6c1bc3..c1a023c3 100644 --- a/src/app/(protected)/__tests__/ProtectedLayout.test.tsx +++ b/src/app/(protected)/__tests__/ProtectedLayout.test.tsx @@ -3,10 +3,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, act, waitFor } from '@testing-library/react'; import ProtectedLayout from '../layout'; import { useScroll } from 'framer-motion'; -import { createClient } from '@/lib/supabase/client'; -import { handleLogout, isAuthSessionMissingError } from '@/lib/security/auth'; -import { logger } from '@/lib/logger'; -import * as Sentry from "@sentry/nextjs"; // Mock hooks vi.mock("@/hooks/users/institutions", () => ({ @@ -30,44 +26,6 @@ vi.mock("@/components/error-boundary", () => ({ ErrorBoundary: ({ children }: any) =>
{children}
, })); -// Mock Supabase -vi.mock('@/lib/supabase/client', () => { - const mockUser = { id: '123' }; - const mockGetUser = vi.fn().mockResolvedValue({ data: { user: mockUser }, error: null }); - const client = { - auth: { - getUser: mockGetUser, - }, - }; - return { - createClient: vi.fn(() => client), - }; -}); - -// Mock Auth logic -vi.mock('@/lib/security/auth', () => ({ - handleLogout: vi.fn().mockResolvedValue(undefined), - isAuthSessionMissingError: vi.fn().mockReturnValue(false), -})); - -vi.mock('@/lib/logger', () => ({ - logger: { - error: vi.fn(), - }, -})); - -vi.mock('@sentry/nextjs', () => ({ - captureException: vi.fn(), -})); - -// Mock next/navigation -const mockReplace = vi.fn(); -vi.mock('next/navigation', () => ({ - useRouter: () => ({ - replace: mockReplace, - }), -})); - // Mock framer-motion vi.mock('framer-motion', async () => { const React = await import('react'); @@ -105,9 +63,6 @@ describe('ProtectedLayout', () => { vi.mocked(useScroll).mockReturnValue({ scrollY: { on: mockOn } as any, } as any); - - const client = createClient(); - (client.auth.getUser as any).mockResolvedValue({ data: { user: { id: '123' } }, error: null }); }); it('renders children and essential components', async () => { @@ -148,56 +103,4 @@ describe('ProtectedLayout', () => { expect(screen.getByTestId('motion-div').getAttribute('data-animate')).toBe('visible'); }); }); - - it('handles auth session missing error', async () => { - const client = createClient(); - const authError = { message: 'Session missing' }; - (client.auth.getUser as any).mockResolvedValue({ data: { user: null }, error: authError }); - vi.mocked(isAuthSessionMissingError).mockReturnValue(true); - - render(
Content
); - - await waitFor(() => { - expect(handleLogout).toHaveBeenCalled(); - }); - }); - - it('handles generic auth error and logs out', async () => { - const client = createClient(); - const authError = new Error('Database down'); - (client.auth.getUser as any).mockResolvedValue({ data: { user: null }, error: authError }); - vi.mocked(isAuthSessionMissingError).mockReturnValue(false); - - render(
Content
); - - await waitFor(() => { - expect(logger.error).toHaveBeenCalledWith("Auth check failed:", "Database down"); - expect(Sentry.captureException).toHaveBeenCalledWith(authError, expect.any(Object)); - expect(handleLogout).toHaveBeenCalled(); - }); - }); - - it('handles case where user is null but no error', async () => { - const client = createClient(); - (client.auth.getUser as any).mockResolvedValue({ data: { user: null }, error: null }); - - render(
Content
); - - await waitFor(() => { - expect(handleLogout).toHaveBeenCalled(); - }); - }); - - it('redirects to root if logout fails after auth error', async () => { - const client = createClient(); - const authError = new Error('Auth failed'); - (client.auth.getUser as any).mockResolvedValue({ data: { user: null }, error: authError }); - vi.mocked(handleLogout).mockRejectedValueOnce(new Error('Logout failed')); - - render(
Content
); - - await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/'); - }); - }); }); diff --git a/src/app/(protected)/__tests__/layout.test.tsx b/src/app/(protected)/__tests__/layout.test.tsx index a6820120..77c3067f 100644 --- a/src/app/(protected)/__tests__/layout.test.tsx +++ b/src/app/(protected)/__tests__/layout.test.tsx @@ -1,18 +1,9 @@ /** @vitest-environment jsdom */ import { describe, it, vi, expect, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import ProtectedLayout from '../layout'; -import { useRouter } from 'next/navigation'; -import { createClient } from '@/lib/supabase/client'; -import { handleLogout } from '@/lib/security/auth'; // Mock all the things -vi.mock('next/navigation', () => ({ - useRouter: vi.fn(() => ({ - replace: vi.fn(), - })), -})); - vi.mock('@/hooks/users/institutions', () => ({ useInstitutions: vi.fn(), })); @@ -21,29 +12,6 @@ vi.mock('@/hooks/use-csrf-token', () => ({ useCSRFToken: vi.fn(), })); -vi.mock('@/lib/supabase/client', () => ({ - createClient: vi.fn(() => ({ - auth: { - getUser: vi.fn(), - }, - })), -})); - -vi.mock('@/lib/security/auth', () => ({ - handleLogout: vi.fn(), - isAuthSessionMissingError: vi.fn((err) => err.message === 'session missing'), -})); - -vi.mock('@/lib/logger', () => ({ - logger: { - error: vi.fn(), - }, -})); - -vi.mock('@sentry/nextjs', () => ({ - captureException: vi.fn(), -})); - vi.mock('framer-motion', async (importOriginal) => { const actual = await importOriginal() as any; return { @@ -73,23 +41,11 @@ vi.mock('@/components/error-boundary', () => ({ })); describe('ProtectedLayout', () => { - const mockRouter = { - replace: vi.fn(), - }; - beforeEach(() => { vi.clearAllMocks(); - vi.mocked(useRouter).mockReturnValue(mockRouter as any); }); - it('renders children and navbar/footer when authenticated', async () => { - const mockSupabase = { - auth: { - getUser: vi.fn().mockResolvedValue({ data: { user: { id: '123' } }, error: null }), - }, - }; - vi.mocked(createClient).mockReturnValue(mockSupabase as any); - + it('renders children and navbar/footer', () => { render(
Protected Content
@@ -99,67 +55,5 @@ describe('ProtectedLayout', () => { expect(screen.getByTestId('navbar')).toBeInTheDocument(); expect(screen.getByTestId('footer')).toBeInTheDocument(); expect(screen.getByTestId('child')).toBeInTheDocument(); - - await waitFor(() => { - expect(mockSupabase.auth.getUser).toHaveBeenCalled(); - }); - }); - - it('calls handleLogout when user is missing', async () => { - const mockSupabase = { - auth: { - getUser: vi.fn().mockResolvedValue({ data: { user: null }, error: null }), - }, - }; - vi.mocked(createClient).mockReturnValue(mockSupabase as any); - - render( - -
Content
-
- ); - - await waitFor(() => { - expect(handleLogout).toHaveBeenCalled(); - }); - }); - - it('calls handleLogout when session missing error occurs', async () => { - const mockSupabase = { - auth: { - getUser: vi.fn().mockResolvedValue({ data: { user: null }, error: new Error('session missing') }), - }, - }; - vi.mocked(createClient).mockReturnValue(mockSupabase as any); - - render( - -
Content
-
- ); - - await waitFor(() => { - expect(handleLogout).toHaveBeenCalled(); - }); - }); - - it('redirects to root when auth check fails completely', async () => { - const mockSupabase = { - auth: { - getUser: vi.fn().mockRejectedValue(new Error('Fatal auth error')), - }, - }; - vi.mocked(createClient).mockReturnValue(mockSupabase as any); - vi.mocked(handleLogout).mockRejectedValue(new Error('Logout failed')); - - render( - -
Content
-
- ); - - await waitFor(() => { - expect(mockRouter.replace).toHaveBeenCalledWith('/'); - }); }); }); diff --git a/src/app/(protected)/dashboard/DashboardClient.tsx b/src/app/(protected)/dashboard/DashboardClient.tsx index 41e39a13..0b6ae893 100644 --- a/src/app/(protected)/dashboard/DashboardClient.tsx +++ b/src/app/(protected)/dashboard/DashboardClient.tsx @@ -273,9 +273,18 @@ export default function DashboardClient({ initialData, serverError }: DashboardC return () => clearTimeout(timer); }, [syncCompleted, profile]); + const isAttendanceStale = + initialData?.attendance && + typeof initialData.attendance === "object" && + "studentAttendanceData" in initialData.attendance && + initialData.attendance.studentAttendanceData && + typeof initialData.attendance.studentAttendanceData === "object" && + "stale" in initialData.attendance.studentAttendanceData && + (initialData.attendance.studentAttendanceData as { stale?: unknown }).stale === true; + const { data: rawAttendanceData, isLoading: isLoadingAttendance, refetch: refetchAttendance } = useAttendanceReport(currentSem, currentYear, { enabled: syncCompleted, - initialData: initialData?.attendance as AttendanceReport ?? undefined, + initialData: isAttendanceStale ? undefined : (initialData?.attendance as AttendanceReport ?? undefined), }); const attendanceData = rawAttendanceData as AttendanceReport | undefined; diff --git a/src/app/(protected)/dashboard/__tests__/DashboardClient.basic.test.tsx b/src/app/(protected)/dashboard/__tests__/DashboardClient.basic.test.tsx index ae5e13e6..71afe5cf 100644 --- a/src/app/(protected)/dashboard/__tests__/DashboardClient.basic.test.tsx +++ b/src/app/(protected)/dashboard/__tests__/DashboardClient.basic.test.tsx @@ -61,6 +61,7 @@ vi.mock('lucide-react', async () => { XCircle: Icon, XIcon: Icon, BookOpen: Icon, + GraduationCap: Icon, }; }); diff --git a/src/app/(public)/build-info/page.tsx b/src/app/(public)/build-info/page.tsx index 70941363..6f709c37 100644 --- a/src/app/(public)/build-info/page.tsx +++ b/src/app/(public)/build-info/page.tsx @@ -64,7 +64,9 @@ function getBuildMeta(): BuildMeta { github_repo: githubRepo, app_version: process.env.NEXT_PUBLIC_APP_VERSION ?? "dev", image_digest: imageDigest, - container: commitSha !== "", + container: + !!process.env.APP_COMMIT_SHA && + process.env.APP_COMMIT_SHA !== "", node_env: process.env.NODE_ENV ?? "development", timestamp: buildTimestamp, audit_status: auditStatus, diff --git a/src/app/api/auth/save-token/route.ts b/src/app/api/auth/save-token/route.ts index bb823221..8660ee65 100644 --- a/src/app/api/auth/save-token/route.ts +++ b/src/app/api/auth/save-token/route.ts @@ -194,6 +194,76 @@ async function provisionSupabaseAuthUser( return { authUserId: retry.user.id, passwordToUse: canonicalPass, isFirstLogin: true }; } +async function signInToSupabase( + supabase: ReturnType, + supabaseAdmin: ReturnType, + authUserId: string, + email: string, + passwordToUse: string +) { + let signInEmail = email; + let signInRes = await supabase.auth.signInWithPassword({ + email: signInEmail, + password: passwordToUse, + }); + if (signInRes.error) { + // First attempt failed β€” try to resolve the canonical email and retry. + try { + const { data: adminUserData } = await supabaseAdmin.auth.admin.getUserById(authUserId); + const fetchedEmail = adminUserData?.user?.email; + if (fetchedEmail && typeof fetchedEmail === "string" && fetchedEmail.trim().length > 0) { + signInEmail = fetchedEmail; + signInRes = await supabase.auth.signInWithPassword({ + email: signInEmail, + password: passwordToUse, + }); + } + } catch { + // If admin lookup fails, preserve the original sign-in error below. + } + } + + if (signInRes.error) { + throw signInRes.error; + } + + return signInRes.data; +} + +async function upsertUserData( + supabaseAdmin: ReturnType, + payload: { + verifiedId: string; + username: string | null; + token: string; + authUserId: string; + fcm_token?: string; + isFirstLogin: boolean; + passwordToUse: string; + } +) { + const { verifiedId, username, token, authUserId, fcm_token, isFirstLogin, passwordToUse } = payload; + const { iv: tIv, content: tContent } = encrypt(token); + const updateData: Record = { + id: verifiedId, + username, + ezygo_token: tContent, + ezygo_iv: tIv, + auth_id: authUserId, + updated_at: new Date().toISOString(), + ...(fcm_token && { fcm_token }), + }; + + if (isFirstLogin) { + const { iv: pIv, content: pContent } = encrypt(passwordToUse); + updateData.auth_password = pContent; + updateData.auth_password_iv = pIv; + } + + const { error: upsertErr } = await supabaseAdmin.from("users").upsert(updateData); + if (upsertErr) throw new Error("Upsert failed"); +} + async function validateRequestHeaders(headerList: Headers, isAppCheck: boolean): Promise { const originError = await validateOrigin(headerList, isAppCheck); if (originError) { @@ -289,57 +359,23 @@ const handler = async ( } ); - // Attempt to sign in using the constructed email first to avoid an - // unnecessary admin API lookup in the common case. If that sign-in - // fails, fall back to fetching the canonical email from the admin API - // and retry once. - let signInEmail = email; - let signInRes = await supabase.auth.signInWithPassword({ - email: signInEmail, - password: passwordToUse, - }); - if (signInRes.error) { - // First attempt failed β€” try to resolve the canonical email and retry. - try { - const { data: adminUserData } = await supabaseAdmin.auth.admin.getUserById(authUserId); - const fetchedEmail = adminUserData?.user?.email; - if (fetchedEmail && typeof fetchedEmail === "string" && fetchedEmail.trim().length > 0) { - signInEmail = fetchedEmail; - signInRes = await supabase.auth.signInWithPassword({ - email: signInEmail, - password: passwordToUse, - }); - } - } catch { - // If admin lookup fails, preserve the original sign-in error below. - } - } - - if (signInRes.error) { - throw signInRes.error; - } - - const signInData = signInRes.data; + const signInData = await signInToSupabase( + supabase, + supabaseAdmin, + authUserId, + email, + passwordToUse + ); - const { iv: tIv, content: tContent } = encrypt(token); - const updateData: Record = { - id: verifiedId, + await upsertUserData(supabaseAdmin, { + verifiedId, username: ezyUser.username, - ezygo_token: tContent, - ezygo_iv: tIv, - auth_id: authUserId, - updated_at: new Date().toISOString(), - ...(fcm_token && { fcm_token }), - }; - - if (isFirstLogin) { - const { iv: pIv, content: pContent } = encrypt(passwordToUse); - updateData.auth_password = pContent; - updateData.auth_password_iv = pIv; - } - - const { error: upsertErr } = await supabaseAdmin.from("users").upsert(updateData); - if (upsertErr) throw new Error("Upsert failed"); + token, + authUserId, + fcm_token, + isFirstLogin, + passwordToUse, + }); const syncRes = await performProfileSync(token, verifiedId, authUserId); const info = calculateCurrentAcademicInfo(); diff --git a/src/app/api/provenance/route.ts b/src/app/api/provenance/route.ts index b19238df..b7a25a6d 100644 --- a/src/app/api/provenance/route.ts +++ b/src/app/api/provenance/route.ts @@ -22,7 +22,10 @@ export function GET(req: Request) { build_id: githubRunId || commitSha, app_version: process.env.NEXT_PUBLIC_APP_VERSION ?? "dev", image_digest: imageDigest, - container: Boolean(commitSha !== ""), + container: Boolean( + process.env.APP_COMMIT_SHA && + process.env.APP_COMMIT_SHA !== "" + ), timestamp: buildTimestamp, audit_status: auditStatus, signature_status: signatureStatus, diff --git a/src/components/attendance/SelectClassDialog.tsx b/src/components/attendance/SelectClassDialog.tsx index fd95f540..22a98978 100644 --- a/src/components/attendance/SelectClassDialog.tsx +++ b/src/components/attendance/SelectClassDialog.tsx @@ -55,6 +55,7 @@ export function SelectClassDialog({ setClasses(res); setSelectedClassId(""); } catch (err) { + console.error("Failed to load classes:", err); toast.error("Failed to load classes"); } finally { setIsLoadingClasses(false); @@ -78,8 +79,9 @@ export function SelectClassDialog({ // Invalidate profile query to update UI state queryClient.invalidateQueries({ queryKey: ["profile"] }); onOpenChange(false); - } catch (err: any) { - toast.error(err.message || "Failed to save class selection"); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to save class selection"; + toast.error(errorMessage); } finally { setIsSubmitting(false); } diff --git a/src/components/layout/__tests__/private-navbar.test.tsx b/src/components/layout/__tests__/private-navbar.test.tsx index 87d2ad65..80c5baeb 100644 --- a/src/components/layout/__tests__/private-navbar.test.tsx +++ b/src/components/layout/__tests__/private-navbar.test.tsx @@ -169,7 +169,7 @@ describe("Navbar", () => { const menuItems = [ { text: "Dashboard", path: "/dashboard" }, { text: "Tracking", path: "/tracking" }, - { text: "Internal Marks", path: "/scores" }, + { text: "Scores", path: "/scores" }, { text: "Leave Applications", path: "/leave-applications" }, { text: "Profile", path: "/profile" }, { text: "Help & FAQ", path: "/help" }, @@ -284,7 +284,7 @@ describe("Navbar", () => { fireEvent.click(trackingBtn); expect(mockRouterPush).toHaveBeenCalledWith("/tracking"); - const scoresBtn = screen.getByRole("button", { name: /Internal Marks/i }); + const scoresBtn = screen.getByRole("button", { name: /Scores/i }); fireEvent.click(scoresBtn); expect(mockRouterPush).toHaveBeenCalledWith("/scores"); }); diff --git a/src/components/user/__tests__/login-form.test.tsx b/src/components/user/__tests__/login-form.test.tsx index 0396177a..099bf208 100644 --- a/src/components/user/__tests__/login-form.test.tsx +++ b/src/components/user/__tests__/login-form.test.tsx @@ -380,6 +380,8 @@ describe("LoginForm – EzyGo credential error message override", () => { }); describe("LoginForm – CSRF re-fetch on null token", () => { + const originalLocation = window.location; + beforeEach(() => { vi.clearAllMocks(); mockGetSession.mockResolvedValue({ data: { session: null }, error: null }); @@ -387,10 +389,14 @@ describe("LoginForm – CSRF re-fetch on null token", () => { vi.mocked(isSupabaseLockTimeoutError).mockReturnValue(false); // Restore the default CSRF token after any test that may have overridden it. vi.mocked(getCsrfToken).mockReturnValue("test-csrf-token"); + + delete (window as any).location; + (window as any).location = { ...originalLocation, href: "" }; }); afterEach(() => { vi.unstubAllGlobals(); + (window as any).location = originalLocation; }); it("re-fetches and stores the CSRF token when getCsrfToken returns null, then completes login", async () => { @@ -416,7 +422,7 @@ describe("LoginForm – CSRF re-fetch on null token", () => { fireEvent.submit(passwordInput.closest("form")!); }); - await waitFor(() => expect(mockRouter.push).toHaveBeenCalledWith("/dashboard")); + await waitFor(() => expect(window.location.href).toBe("/dashboard")); expect(vi.mocked(setCsrfToken)).toHaveBeenCalledWith("refetched-csrf-token"); }); diff --git a/src/lib/email.ts b/src/lib/email.ts index 8e85d0f9..bb2b077e 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -200,22 +200,40 @@ async function executeFailover( } } -export async function sendEmail(props: SendEmailProps): Promise { - const useSP = shouldUseSendPulse(); - const primary: ProviderFn = useSP ? sendViaSendPulse : sendViaBrevo; - let secondary: ProviderFn | null = null; - if (hasBrevo && hasSendPulse) { - secondary = useSP ? sendViaBrevo : sendViaSendPulse; +interface ProvidersConfig { + primary: ProviderFn; + secondary: ProviderFn | null; + pName: "SendPulse" | "Brevo"; + sName: "Brevo" | "SendPulse"; +} + +function getProviders(useSP: boolean): ProvidersConfig { + if (useSP) { + return { + primary: sendViaSendPulse, + secondary: hasBrevo ? sendViaBrevo : null, + pName: "SendPulse", + sName: "Brevo", + }; } - const pName: "SendPulse" | "Brevo" = useSP ? "SendPulse" : "Brevo"; - const sName: "Brevo" | "SendPulse" = useSP ? "Brevo" : "SendPulse"; + return { + primary: sendViaBrevo, + secondary: hasSendPulse ? sendViaSendPulse : null, + pName: "Brevo", + sName: "SendPulse", + }; +} +export async function sendEmail(props: SendEmailProps): Promise { if (!hasBrevo && !hasSendPulse) { const err = new Error("No provider"); Sentry.captureException(err, { tags: { location: "sendEmail" } }); throw err; } + const useSP = shouldUseSendPulse(); + const { primary, secondary, pName, sName } = getProviders(useSP); + try { return await primary(props); } catch (err: unknown) { diff --git a/src/lib/user/sync.ts b/src/lib/user/sync.ts index 5db46aa7..fef1bfce 100644 --- a/src/lib/user/sync.ts +++ b/src/lib/user/sync.ts @@ -114,7 +114,9 @@ function extractAcademicSettingValue(raw: unknown, primaryKey: string, fallbackK return null; } -function normalizeSemester(semVal: unknown): "even" | "odd" | null { +type SemesterType = "even" | "odd"; + +function normalizeSemester(semVal: unknown): SemesterType | null { if (!semVal) return null; const semStr = String(semVal).toLowerCase(); if (semStr.includes("odd") || semStr === "1") { @@ -206,113 +208,297 @@ function cleanClassName(input: string): string { .trim(); } -async function detectAndSyncClass( - coursesList: CourseItem[], - _rolesData: unknown, - currentAcademic: { current_semester: string; current_year: string }, - existingUserClassId: string | null | undefined -): Promise<{ - classId: string | null; - classInfo: { id: string; name: string } | null; - detectedSem?: "even" | "odd" | null; - detectedYear?: string | null; -}> { +async function upsertManualClass( + pcg: number | null, + sem: SemesterType, + year: string, + formattedName: string +): Promise<{ id: string; name: string } | null> { const supabaseAdmin = getAdminClient(); + try { + const upsertPayload: Record = { + programme_config_group_id: pcg != null ? Number(pcg) : null, + sem, + year, + name: shortTextSchema.parse(formattedName), + }; - if (coursesList.length === 0) { - if (existingUserClassId) { - const { data: currentClass, error } = await supabaseAdmin - .from("classes") - .select("*") - .eq("id", existingUserClassId) - .maybeSingle(); + const { data: newClass, error: upsertErr } = await supabaseAdmin + .from("classes") + .upsert(upsertPayload, { onConflict: "programme_config_group_id, sem, year, name" }) + .select("id, name") + .single(); - if (error) { - logger.error("[sync] detectAndSyncClass: Failed to fetch existing user class", error); - } + if (upsertErr || !newClass) { + logger.error("[sync] detectAndSyncClass: Failed to clone/manual-upsert class", upsertErr); + return null; + } + return newClass; + } catch (err) { + logger.error("[sync] detectAndSyncClass: Exception cloning manual class", err instanceof Error ? err : new Error(String(err))); + return null; + } +} - if (currentClass) { - const currentClassId = currentClass.id ?? currentClass.class_id ?? existingUserClassId ?? null; - const currentClassName = typeof currentClass.name === "string" && currentClass.name.trim() !== "" - ? currentClass.name - : "Class"; +async function detectClassWithoutCourses( + existingUserClassId: string | null | undefined, + currentAcademic: { current_semester: string; current_year: string } +): Promise<{ classId: string | null; classInfo: { id: string; name: string } | null }> { + if (!existingUserClassId) { + return { classId: null, classInfo: null }; + } - if (currentClass.sem === currentAcademic.current_semester && currentClass.year === currentAcademic.current_year) { - return { classId: currentClassId, classInfo: { id: currentClassId, name: currentClassName } }; - } + const supabaseAdmin = getAdminClient(); + const { data: currentClass, error } = await supabaseAdmin + .from("classes") + .select("*") + .eq("id", existingUserClassId) + .maybeSingle(); + + if (error) { + logger.error("[sync] detectAndSyncClass: Failed to fetch existing user class", error); + } - if (typeof currentClass.name !== "string" || currentClass.name.trim() === "") { - logger.warn("[sync] detectAndSyncClass: Existing class has no name; keeping current class without cloning", { - classId: currentClassId, - }); - return { classId: currentClassId, classInfo: { id: currentClassId, name: currentClassName } }; - } + if (!currentClass) { + return { classId: null, classInfo: null }; + } - const pcg = currentClass.programme_config_group_id; - const sem = normalizeSemester(currentAcademic.current_semester) || "even"; - const year = currentAcademic.current_year; - const name = cleanClassName(currentClass.name); - const formattedName = `${name} ${sem} ${year}`.trim().replace(/\s+/g, ' '); - - let query = supabaseAdmin - .from("classes") - .select("id, name") - .eq("sem", sem) - .eq("year", year) - .eq("name", shortTextSchema.parse(formattedName)); - - if (pcg != null) { - query = query.eq("programme_config_group_id", Number(pcg)); - } else { - query = query.is("programme_config_group_id", null); - } + const currentClassId = currentClass.id ?? currentClass.class_id ?? existingUserClassId ?? null; + const currentClassName = typeof currentClass.name === "string" && currentClass.name.trim() !== "" + ? currentClass.name + : "Class"; - const { data: matchClasses } = await query; - const found = matchClasses?.[0]; + const isCurrentAcademic = currentClass.sem === currentAcademic.current_semester && currentClass.year === currentAcademic.current_year; + if (isCurrentAcademic) { + return { classId: currentClassId, classInfo: { id: currentClassId, name: currentClassName } }; + } - if (found) { - return { classId: found.id, classInfo: { id: found.id, name: found.name } }; - } + const hasNoName = typeof currentClass.name !== "string" || currentClass.name.trim() === ""; + if (hasNoName) { + logger.warn("[sync] detectAndSyncClass: Existing class has no name; keeping current class without cloning", { + classId: currentClassId, + }); + return { classId: currentClassId, classInfo: { id: currentClassId, name: currentClassName } }; + } - try { - const upsertPayload: Record = { - programme_config_group_id: pcg != null ? Number(pcg) : null, - sem, - year, - name: shortTextSchema.parse(formattedName), - }; - - const { data: newClass, error: upsertErr } = await supabaseAdmin - .from("classes") - .upsert(upsertPayload, { onConflict: "programme_config_group_id, sem, year, name" }) - .select("id, name") - .single(); - - if (upsertErr || !newClass) { - logger.error("[sync] detectAndSyncClass: Failed to clone/manual-upsert class", upsertErr); - } else { - return { classId: newClass.id, classInfo: newClass }; - } - } catch (err) { - logger.error("[sync] detectAndSyncClass: Exception cloning manual class", err instanceof Error ? err : new Error(String(err))); - } - } - } - return { classId: null, classInfo: null }; + const pcg = currentClass.programme_config_group_id; + const sem = normalizeSemester(currentAcademic.current_semester) || "even"; + const year = currentAcademic.current_year; + const name = cleanClassName(currentClass.name); + const formattedName = `${name} ${sem} ${year}`.trim().replace(/\s+/g, ' '); + + let query = supabaseAdmin + .from("classes") + .select("id, name") + .eq("sem", sem) + .eq("year", year) + .eq("name", shortTextSchema.parse(formattedName)); + + if (pcg != null) { + query = query.eq("programme_config_group_id", Number(pcg)); + } else { + query = query.is("programme_config_group_id", null); + } + + const { data: matchClasses } = await query; + const found = matchClasses?.[0]; + + if (found) { + return { classId: found.id, classInfo: { id: found.id, name: found.name } }; } - let courseWithGroup = coursesList.find( + const newClass = await upsertManualClass(pcg, sem, year, formattedName); + if (newClass) { + return { classId: newClass.id, classInfo: newClass }; + } + + return { classId: null, classInfo: null }; +} + +function findCourseWithGroup(coursesList: CourseItem[]): CourseItem | undefined { + const primary = coursesList.find( c => (c.usersubgroup?.programme_config_group_id != null || c.usersubgroup?.usergroup?.id != null) && c.usersubgroup?.usergroup?.name != null ); + if (primary) return primary; - if (!courseWithGroup) { - courseWithGroup = coursesList.find( - c => c.usersubgroup?.programme_config_group_id != null - || c.usersubgroup?.usergroup?.id != null - ); + return coursesList.find( + c => c.usersubgroup?.programme_config_group_id != null + || c.usersubgroup?.usergroup?.id != null + ); +} + +async function tryUpdateExistingUserClass( + existingUserClassId: string | null | undefined, + pcg: number, + sem: SemesterType, + year: string, + externalId: number | null, + name: string +): Promise<{ id: string; name: string } | null> { + if (!existingUserClassId) { + return null; + } + + const supabaseAdmin = getAdminClient(); + const { data: userClass, error: fetchUserClassErr } = await supabaseAdmin + .from("classes") + .select("*") + .eq("id", existingUserClassId) + .maybeSingle(); + + if (fetchUserClassErr || !userClass) { + return null; + } + + const matchPcg = userClass.programme_config_group_id === pcg; + const matchTerm = userClass.sem === sem && userClass.year === year; + + if (matchPcg && matchTerm) { + const updatePayload: Record = {}; + if (externalId != null) { + updatePayload.external_group_id = externalId; + } + updatePayload.name = name; + + const { error: updateErr } = await supabaseAdmin + .from("classes") + .update(updatePayload) + .eq("id", userClass.id); + + if (updateErr) { + logger.error("[sync] detectAndSyncClass: Failed to update user's existing class with official EzyGo metadata", updateErr); + } + return { id: userClass.id, name }; + } + + return null; +} + +async function findOrCreateCohortClass( + pcg: number, + sem: SemesterType, + year: string, + externalId: number | null, + name: string +): Promise<{ id: string; name: string } | null> { + const supabaseAdmin = getAdminClient(); + + const { data: existingClasses, error: fetchErr } = await supabaseAdmin + .from("classes") + .select("id, name") + .eq("programme_config_group_id", pcg) + .eq("sem", sem) + .eq("year", year) + .eq("name", name); + + if (fetchErr) { + logger.error("[sync] detectAndSyncClass: Failed to fetch class by cohort", fetchErr); } + const matchedClass = existingClasses?.[0]; + + if (matchedClass) { + if (externalId != null) { + const { error: updateErr } = await supabaseAdmin + .from("classes") + .update({ external_group_id: externalId }) + .eq("id", matchedClass.id); + if (updateErr) { + logger.error("[sync] detectAndSyncClass: Failed to update class metadata", updateErr); + } + } + return { id: matchedClass.id, name }; + } + + try { + const upsertPayload: Record = { + programme_config_group_id: pcg, + sem, + year, + name, + }; + if (externalId != null) { + upsertPayload.external_group_id = externalId; + } + + const { data: newClass, error: upsertErr } = await supabaseAdmin + .from("classes") + .upsert(upsertPayload, { onConflict: "programme_config_group_id, sem, year, name" }) + .select("id, name") + .single(); + + if (upsertErr || !newClass) { + logger.error("[sync] detectAndSyncClass: Failed to upsert new class", upsertErr); + return null; + } + return newClass; + } catch (err) { + logger.error("[sync] detectAndSyncClass: Exception upserting new class", err instanceof Error ? err : new Error(String(err))); + return null; + } +} + +async function syncCohortClass( + pcg: number, + sem: SemesterType, + year: string, + externalId: number | null, + name: string, + existingUserClassId: string | null | undefined +): Promise<{ + classId: string | null; + classInfo: { id: string; name: string } | null; + detectedSem?: SemesterType; + detectedYear?: string; +}> { + const existingMatched = await tryUpdateExistingUserClass( + existingUserClassId, + pcg, + sem, + year, + externalId, + name + ); + + if (existingMatched) { + return { + classId: existingMatched.id, + classInfo: existingMatched, + detectedSem: sem, + detectedYear: year, + }; + } + + const cohortClass = await findOrCreateCohortClass(pcg, sem, year, externalId, name); + if (cohortClass) { + return { + classId: cohortClass.id, + classInfo: cohortClass, + detectedSem: sem, + detectedYear: year, + }; + } + + return { classId: null, classInfo: null }; +} + +async function detectAndSyncClass( + coursesList: CourseItem[], + _rolesData: unknown, + currentAcademic: { current_semester: string; current_year: string }, + existingUserClassId: string | null | undefined +): Promise<{ + classId: string | null; + classInfo: { id: string; name: string } | null; + detectedSem?: SemesterType | null; + detectedYear?: string | null; +}> { + if (coursesList.length === 0) { + return detectClassWithoutCourses(existingUserClassId, currentAcademic); + } + + const courseWithGroup = findCourseWithGroup(coursesList); if (courseWithGroup?.usersubgroup) { const sub = courseWithGroup.usersubgroup; const pcg = sub.programme_config_group_id ?? sub.usergroup?.id ?? null; @@ -325,101 +511,17 @@ async function detectAndSyncClass( logger.info(`[sync] detectAndSyncClass: cohort pcg=${pcg} sem=${sem} year=${year} externalId=${externalId} name=${nameRaw}`); - // First check if the user already has a class associated, and if it matches this cohort (program ID and end year) - if (existingUserClassId) { - const { data: userClass, error: fetchUserClassErr } = await supabaseAdmin - .from("classes") - .select("*") - .eq("id", existingUserClassId) - .maybeSingle(); - - if (userClass && !fetchUserClassErr) { - const matchPcg = userClass.programme_config_group_id === (pcg != null ? Number(pcg) : null); - const matchTerm = userClass.sem === sem && userClass.year === year; - - if (matchPcg && matchTerm) { - // Update this class with the official EzyGo data. Avoid overwriting external_group_id with null. - const updatePayload: Record = {}; - if (externalId != null) updatePayload.external_group_id = externalId; - updatePayload.name = name; - - if (Object.keys(updatePayload).length > 0) { - const { error: updateErr } = await supabaseAdmin - .from("classes") - .update(updatePayload) - .eq("id", userClass.id); - - if (updateErr) { - logger.error("[sync] detectAndSyncClass: Failed to update user's existing class with official EzyGo metadata", updateErr); - } - } - return { classId: userClass.id, classInfo: { id: userClass.id, name: name }, detectedSem: sem, detectedYear: year }; - } - } - } - - // Fallback: If user has no class associated or it doesn't match the cohort, find/create class by cohort - const { data: existingClasses, error: fetchErr } = await supabaseAdmin - .from("classes") - .select("id, name") - .eq("programme_config_group_id", Number(pcg)) - .eq("sem", sem) - .eq("year", year) - .eq("name", name); - - if (fetchErr) { - logger.error("[sync] detectAndSyncClass: Failed to fetch class by cohort", fetchErr); - } - - const matchedClass = existingClasses?.[0]; - - if (matchedClass) { - try { - const updatePayload: Record = {}; - if (externalId != null) updatePayload.external_group_id = externalId; - if (Object.keys(updatePayload).length > 0) { - const { error: updateErr } = await supabaseAdmin - .from("classes") - .update(updatePayload) - .eq("id", matchedClass.id); - if (updateErr) { - logger.error("[sync] detectAndSyncClass: Failed to update class metadata", updateErr); - } - } - } catch (err) { - logger.error("[sync] detectAndSyncClass: Exception updating matched class", err instanceof Error ? err : new Error(String(err))); - } - return { classId: matchedClass.id, classInfo: { id: matchedClass.id, name: name }, detectedSem: sem, detectedYear: year }; - } else { - try { - const upsertPayload: Record = { - programme_config_group_id: Number(pcg), - sem, - year, - name, - }; - if (externalId != null) upsertPayload.external_group_id = externalId; - - const { data: newClass, error: upsertErr } = await supabaseAdmin - .from("classes") - .upsert(upsertPayload, { onConflict: "programme_config_group_id, sem, year, name" }) - .select("id, name") - .single(); - - if (upsertErr || !newClass) { - logger.error("[sync] detectAndSyncClass: Failed to upsert new class", upsertErr); - } else { - return { classId: newClass.id, classInfo: newClass, detectedSem: sem, detectedYear: year }; - } - } catch (err) { - logger.error("[sync] detectAndSyncClass: Exception upserting new class", err instanceof Error ? err : new Error(String(err))); - } - } + return syncCohortClass( + Number(pcg), + sem, + year, + externalId, + name, + existingUserClassId + ); } } - // No primary subgroup fallback: require cohort data from coursesList to create/identify classes. - return { classId: null, classInfo: null }; } @@ -553,7 +655,7 @@ function resolveMergedProfile( export interface LightSyncResult { academic: { year: string | null; - semester: "even" | "odd" | null; + semester: SemesterType | null; current_year: string; current_semester: string; }; diff --git a/workers/package-lock.json b/workers/package-lock.json index 9b822f62..c1729930 100644 --- a/workers/package-lock.json +++ b/workers/package-lock.json @@ -8,7 +8,7 @@ "name": "wrangler-install", "version": "1.0.0", "dependencies": { - "wrangler": "^4.93.0" + "wrangler": "^4.94.0" } }, "node_modules/@cloudflare/kv-asset-handler": { @@ -36,9 +36,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260518.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260518.1.tgz", - "integrity": "sha512-IhZEf5kDd0CLRtFxGS9AUqfM5SY3EFScqqCY1VF9twNMdYpJDYrDZDJAkQitHF8sF/sPVVHYR4Aifpdq6tzmaA==", + "version": "1.20260521.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260521.1.tgz", + "integrity": "sha512-aiNdXmxlhwGjTSajL3I7uQPpN4lAOcXjvg5ZOlJKIywnevr798n9XCS6lvuqgniM3KjurBNWRRypMJntg/eSLg==", "cpu": [ "x64" ], @@ -52,9 +52,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260518.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260518.1.tgz", - "integrity": "sha512-uqlNP1psd8SWfN1Lg5p8ePv8/piOOXt+ycvb8+NQopXECGeh9+PQ/yr/IQjpurxBhYpvSaMC+vEeihejahjkJg==", + "version": "1.20260521.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260521.1.tgz", + "integrity": "sha512-ikN8aKSi4Ak28ndOkuSO5rq6lmV6wwDQu9F9Vu6J7EkwAOth74J/Hjn4j4EuFceW/npw2Ws0Y/muzA6WKHl4TA==", "cpu": [ "arm64" ], @@ -68,9 +68,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260518.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260518.1.tgz", - "integrity": "sha512-D9p8Hl0lIQ46nYs4fQZp5F+9hhvgOcQJTF1SMQWpAxQSS5f8oX+vL5YdCrETUYnyoaoyEQETtkRrWYKJkPTFeg==", + "version": "1.20260521.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260521.1.tgz", + "integrity": "sha512-D/gUhvQcG0pJr5aJl6yUoi2JxbFpjVtDq9xUJHPjfkAjL28TUVgCR/e5r8YGirepv4I1DK7ihuii9LZ2GGMJbw==", "cpu": [ "x64" ], @@ -84,9 +84,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260518.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260518.1.tgz", - "integrity": "sha512-+vNRkuOp9E/uRKHgQXVDUBPF5cwtTeXK6+ucLK50QUFzMYycqVl8kTFN2b//BX2H5BI4bjMRhXoBpe/zAlGRWQ==", + "version": "1.20260521.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260521.1.tgz", + "integrity": "sha512-vhjWPIHenczegTakhRPwEmTeaavCpNqsuo3RlLCkUdU47HrwLvy/4QersGggs4+kF4Do+IE/EznCGyT40xYcLA==", "cpu": [ "arm64" ], @@ -100,9 +100,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260518.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260518.1.tgz", - "integrity": "sha512-tnqofUq+ZvKliQHhboygbH7iy/Zm/MaCCotIlrqVj5a988+tPtndxyLM0r4vaAIC10iy/2LWCkwnE67VFTFiUA==", + "version": "1.20260521.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260521.1.tgz", + "integrity": "sha512-wBolYC/+lnGIEbkkPdzFtjTOWip2uQH6maeAP1ZV0kyxi5SGpsa83+wD5rH5OOle+sHE5qJMdwCKjwRwj+FKJg==", "cpu": [ "x64" ], @@ -1237,16 +1237,16 @@ } }, "node_modules/miniflare": { - "version": "4.20260518.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260518.0.tgz", - "integrity": "sha512-jbvp43zWa66tuQ+P7bl7s25VJWzGMv4mVhxEEZEEATPvuqAQhGn2wj3rQViVZkZZBZmXQtZ5ZV5kX9VtmWGzuA==", + "version": "4.20260521.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260521.0.tgz", + "integrity": "sha512-roRfxPq49OkuSeQsc43hRjSB1+HdHtDNKRwDEVk2hCjCBuBWxb5Wvwq88b0ULj6QVEJLN/+ZqF19M+h4VYJ/zg==", "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", - "workerd": "1.20260518.1", - "ws": "8.18.0", + "workerd": "1.20260521.1", + "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { @@ -1268,10 +1268,66 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/rosie-skills": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/rosie-skills/-/rosie-skills-0.6.4.tgz", + "integrity": "sha512-ojfhSiQRdZ2QyWbmKAHOSAUbaLYrTc5zIH7mS1jKoP8KCFSQddwVhMyFqldckTeybTfW3zNcsZzyOTzGTN1SBA==", + "license": "BSD-3-Clause", + "bin": { + "rosie-skills": "dist/bin.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "rosie-skills-darwin-arm64": "0.6.4", + "rosie-skills-freebsd-x64": "0.6.4", + "rosie-skills-linux-x64": "0.6.4" + } + }, + "node_modules/rosie-skills-darwin-arm64": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/rosie-skills-darwin-arm64/-/rosie-skills-darwin-arm64-0.6.4.tgz", + "integrity": "sha512-rn1s5hqFKcxeiDEWWoFa1hdGPshR8TkwHLzy/cBavb9XJNAaUxbe3oQ78W9sQkRHAgRyzJYyk9tw68Qrdnizgg==", + "cpu": [ + "arm64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/rosie-skills-freebsd-x64": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/rosie-skills-freebsd-x64/-/rosie-skills-freebsd-x64-0.6.4.tgz", + "integrity": "sha512-SxCRduPBMtfjkQ+q56Yw9OLA3PyaqoALzt7kER7IDKuUVfM2O/1w8sa5xhTDiCvWkZJixnH5d5Ya6KT+/Mwcng==", + "cpu": [ + "x64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/rosie-skills-linux-x64": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/rosie-skills-linux-x64/-/rosie-skills-linux-x64-0.6.4.tgz", + "integrity": "sha512-D9Y9mfu7goB0s0X59uU3hcFeUTef3VbpCIDwFMzyvJrAq3XhRACWBDMHQsHlyWdHxTXPX/ILyW65RXyrJlgqng==", + "cpu": [ + "x64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -1362,9 +1418,9 @@ } }, "node_modules/workerd": { - "version": "1.20260518.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260518.1.tgz", - "integrity": "sha512-rLquk/eeqqJCbdGljSSuIZWW25vzYjTblXkD/tXQXKR5YsSIC91EtlqrzA1L4TJDZCxXKeFXPYqkW7R16UipXQ==", + "version": "1.20260521.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260521.1.tgz", + "integrity": "sha512-HzIThcZ0ZVEuzVxpY2IYZ3yssSrTjtrWXAVfmOl5rVwyqcu7aeZXGMiwrEmi9MOcC3wjy+BNv+hFrMMY5OrjQQ==", "hasInstallScript": true, "license": "Apache-2.0", "bin": { @@ -1374,27 +1430,28 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260518.1", - "@cloudflare/workerd-darwin-arm64": "1.20260518.1", - "@cloudflare/workerd-linux-64": "1.20260518.1", - "@cloudflare/workerd-linux-arm64": "1.20260518.1", - "@cloudflare/workerd-windows-64": "1.20260518.1" + "@cloudflare/workerd-darwin-64": "1.20260521.1", + "@cloudflare/workerd-darwin-arm64": "1.20260521.1", + "@cloudflare/workerd-linux-64": "1.20260521.1", + "@cloudflare/workerd-linux-arm64": "1.20260521.1", + "@cloudflare/workerd-windows-64": "1.20260521.1" } }, "node_modules/wrangler": { - "version": "4.93.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.93.0.tgz", - "integrity": "sha512-qNsPr0oWRTc85SG7s1MjX+mWNTvkNV1zEQvRpTsV6eo8uqtvZoEAq8t8strQi9TtrDP3BOsxmy+N/G3ML6hH2w==", + "version": "4.94.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.94.0.tgz", + "integrity": "sha512-GsNw0DomGFfeXFtKVTwn2X69UKcCxcTB0CXykjsMineJIxOeyrw7LovlHQ/3JU8KJHH7repLB+kOHvfTBA/Eew==", "license": "MIT OR Apache-2.0", "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", - "miniflare": "4.20260518.0", + "miniflare": "4.20260521.0", "path-to-regexp": "6.3.0", + "rosie-skills": "^0.6.3", "unenv": "2.0.0-rc.24", - "workerd": "1.20260518.1" + "workerd": "1.20260521.1" }, "bin": { "wrangler": "bin/wrangler.js", @@ -1407,7 +1464,7 @@ "fsevents": "~2.3.2" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20260518.1" + "@cloudflare/workers-types": "^4.20260521.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -1416,9 +1473,9 @@ } }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/workers/package.json b/workers/package.json index a2fbb794..98c1acdd 100644 --- a/workers/package.json +++ b/workers/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "dependencies": { - "wrangler": "^4.93.0" + "wrangler": "^4.94.0" }, "overrides": { "undici": "8.1.0",