diff --git a/.example.env b/.example.env index 03eee26c..96ce20d2 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.5 +NEXT_PUBLIC_APP_VERSION=4.3.6 # ⚠️ Minimum supported app version required to bypass forced update # 🚀 Runtime (Infisical `/runtime` folder → Server Env Var) -MIN_APP_VERSION=4.3.5 +MIN_APP_VERSION=4.3.6 # ⚠️ 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 cc1bb121..a17139b9 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.5'); + const String.fromEnvironment('APP_VERSION', defaultValue: '4.3.6'); /// Commit SHA injected by CI for release builds. static String get appCommitSha => diff --git a/mobile/lib/logic/ezygo_batch_fetcher.dart b/mobile/lib/logic/ezygo_batch_fetcher.dart index e4d897d5..8a62288f 100644 --- a/mobile/lib/logic/ezygo_batch_fetcher.dart +++ b/mobile/lib/logic/ezygo_batch_fetcher.dart @@ -46,6 +46,8 @@ class EzygoBatchFetcher { // Tracker for log throttling static DateTime? _lastCircuitBreakerLog; + static int _generation = 0; + /// Executes an authenticated request with deduplication and caching. /// /// [path] The full URL or relative path. @@ -62,6 +64,7 @@ class EzygoBatchFetcher { // We include method, path, token, and a hash of the body data to avoid collisions. final dataKey = data != null ? json.encode(data) : ''; final cacheKey = '$method|$path|$token|$dataKey'; + final startGeneration = _generation; // 0. Security Barrier: If the backend connection is compromised, block immediately. if (_isBackendUnauthorized()) { @@ -169,19 +172,26 @@ class EzygoBatchFetcher { final response = await requestFuture; // 5. Cache the result - if (response.statusCode == 200) { - // Success cache (Longer) - _cache[cacheKey] = _CacheEntry( - response: response, - expiry: DateTime.now().add(_cacheTtl), - ); - } else if (response.statusCode != null && response.statusCode! >= 500) { - // NEGATIVE CACHE (Circuit Breaker): - // Remember 5xx failures briefly to prevent Request Storms. - _setOutage(true); - _cache[cacheKey] = _CacheEntry( - response: response, - expiry: DateTime.now().add(const Duration(seconds: 15)), + if (_generation == startGeneration) { + if (response.statusCode == 200) { + // Success cache (Longer) + _cache[cacheKey] = _CacheEntry( + response: response, + expiry: DateTime.now().add(_cacheTtl), + ); + } else if (response.statusCode != null && + response.statusCode! >= 500) { + // NEGATIVE CACHE (Circuit Breaker): + // Remember 5xx failures briefly to prevent Request Storms. + _setOutage(true); + _cache[cacheKey] = _CacheEntry( + response: response, + expiry: DateTime.now().add(const Duration(seconds: 15)), + ); + } + } else { + AppLogger.d( + 'EzygoBatchFetcher: Discarded caching for $path due to cache clear during request.', ); } @@ -193,7 +203,7 @@ class EzygoBatchFetcher { e.type == DioExceptionType.connectionError) { _setOutage(true); // Short error TTL for transient network issues to recover faster - if (e.response != null) { + if (e.response != null && _generation == startGeneration) { _cache[cacheKey] = _CacheEntry( response: e.response!, expiry: DateTime.now().add(const Duration(seconds: 5)), @@ -261,7 +271,10 @@ class EzygoBatchFetcher { _cache.clear(); _inFlight.clear(); _setOutage(false); - AppLogger.i('EzygoBatchFetcher: Cache and Outage state cleared.'); + _generation++; + AppLogger.i( + 'EzygoBatchFetcher: Cache and Outage state cleared. Generation updated to $_generation.', + ); } } diff --git a/mobile/lib/models/attendance.dart b/mobile/lib/models/attendance.dart index 3d116f30..d4a072f1 100644 --- a/mobile/lib/models/attendance.dart +++ b/mobile/lib/models/attendance.dart @@ -187,7 +187,7 @@ enum AttendanceStatus { present(110), absent(111), otherLeave(112), - dutyLeave(225) + dutyLeave(225), ; const AttendanceStatus(this.code); diff --git a/mobile/lib/providers/academic_provider.dart b/mobile/lib/providers/academic_provider.dart index 1b3348d8..11212719 100644 --- a/mobile/lib/providers/academic_provider.dart +++ b/mobile/lib/providers/academic_provider.dart @@ -84,11 +84,10 @@ final academicProvider = class AcademicNotifier extends AsyncNotifier { @override FutureOr build() async { - // Academic state is pre-populated by AuthNotifier during startup - // (via _fetchAndSaveAcademicContext) before authProvider.future resolves. - // We gate on auth being available to avoid building while logged out. - final authAsync = ref.watch(authProvider); - if (!authAsync.hasValue || authAsync.value == null) return null; + // 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); @@ -97,7 +96,6 @@ class AcademicNotifier extends AsyncNotifier { if (cached != null) return cached; // 2. Fallback: User settings from profile sync - final auth = authAsync.value!; final semester = auth.settings.semester; final year = auth.settings.academicYear; diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index 9ed04b27..79b470eb 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -11,7 +11,11 @@ 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/logger.dart'; @@ -198,7 +202,7 @@ class AuthNotifier extends AsyncNotifier final backgroundDuration = now.difference(_lastBackgroundedAt!); if (backgroundDuration < const Duration(minutes: 15)) return; } - final _ = refreshProfile(force: true, sync: true); + final _ = refreshProfile(force: true); } } @@ -252,7 +256,7 @@ class AuthNotifier extends AsyncNotifier } } - await refreshProfile(force: true, sync: true); + await refreshProfile(force: true); final newToken = state.value?.ezygoToken; if (newToken != null && newToken != oldToken) { @@ -326,7 +330,7 @@ class AuthNotifier extends AsyncNotifier await logout(force: true); } - Future refreshProfile({bool force = false, bool sync = false}) async { + Future refreshProfile({bool force = false}) async { final currentUser = state.value; if (currentUser == null) return; @@ -352,7 +356,8 @@ class AuthNotifier extends AsyncNotifier await _fetchAndApplyServerProfile( currentUser, supabaseToken: token, - sync: sync, + sync: force, + force: force, ); } on Object catch (e) { if (e is AppException && e.isAuthError) { @@ -411,18 +416,17 @@ class AuthNotifier extends AsyncNotifier ezygoToken: ezygoToken ?? '', ); - // Run the heavy startup network tasks asynchronously in the background - unawaited(_runBackgroundStartupHydration(user)); + // Synchronously await critical profile sync to block authProvider initialization + // until server profile sync returns 200 successfully. + await _runBackgroundStartupHydration(user); - // Return the cached user immediately with isSyncing set to true. - // This allows the splash screen to resolve instantly (<1s) and navigates to the dashboard, - // which displays the page-wise loading screen until the background tasks complete. - return user.copyWith(isSyncing: true); + return state.value ?? user; } Future _runBackgroundStartupHydration( - AuthenticatedUser cachedUser, - ) async { + AuthenticatedUser cachedUser, { + bool silent = false, + }) async { final api = ref.read(apiServiceProvider)..suppress401 = true; try { final token = await _getFreshSupabaseToken(); @@ -433,29 +437,27 @@ class AuthNotifier extends AsyncNotifier ); } - // Fetch Profile & EzyGo Academic Context concurrently in the background - await Future.wait([ - _fetchAndApplyServerProfile( - cachedUser, - supabaseToken: token, - ), - _fetchAndSaveAcademicContext(token), - ]); + // 1. Fetch Profile and trigger backend full EzyGo sync synchronously + await _fetchAndApplyServerProfile( + cachedUser, + supabaseToken: token, + sync: true, + ); _lastRefresh = DateTime.now(); // Pre-fetch institutions so they are ready in settings unawaited(ref.read(institutionsProvider.future)); - // Trigger server-side EzyGo cron sync - await api.triggerSync(token, force: true); + // 2. Trigger EzyGo cron sync in background (non-blocking) + unawaited(api.triggerSync(token, force: true)); - // Refresh profile after cron so class name, etc. are accurate - await refreshProfile(force: true); - - final finalUser = state.value; - if (finalUser != null && - finalUser.supabaseUserId == cachedUser.supabaseUserId) { - state = AsyncValue.data(finalUser.copyWith(isSyncing: false)); + // If we are not running silently, clear the syncing status to unlock the UI + if (!silent) { + final finalUser = state.value; + if (finalUser != null && + finalUser.supabaseUserId == cachedUser.supabaseUserId) { + state = AsyncValue.data(finalUser.copyWith(isSyncing: false)); + } } } on Object catch (e) { if (e is AppException && e.isAuthError) { @@ -468,114 +470,18 @@ class AuthNotifier extends AsyncNotifier 'AuthNotifier: Background startup hydration failed. Using cached data.', e, ); - // Ensure we clear the syncing status so the user can access the cached dashboard offline, - // but only if the user is still logged in and matches the session that started hydration. - final currentUser = state.value; - if (currentUser != null && - currentUser.supabaseUserId == cachedUser.supabaseUserId) { - state = AsyncValue.data(currentUser.copyWith(isSyncing: false)); + if (!silent) { + final currentUser = state.value; + if (currentUser != null && + currentUser.supabaseUserId == cachedUser.supabaseUserId) { + state = AsyncValue.data(currentUser.copyWith(isSyncing: false)); + } } } finally { api.suppress401 = false; } } - /// Fetches sem/year from EzyGo and persists to secure storage. - /// If EzyGo returns null/empty, calculates from the current date and - /// POSTs the calculated values back to EzyGo so it stays in sync. - /// This is called synchronously during startup — it blocks the splash screen. - Future _fetchAndSaveAcademicContext(String supabaseToken) async { - final api = ref.read(apiServiceProvider); - final storage = ref.read(secureStorageProvider); - - // Clear any previously cached academic state so we always get a fresh value - // (avoids showing last term's data after semester rollover) - try { - final results = await Future.wait([ - api.fetchSemester(storage), - api.fetchAcademicYear(storage), - ]); - - final semRes = results[0] as Response; - final yearRes = results[1] as Response; - - String? extract(dynamic raw, String key) { - if (raw == null) return null; - if (raw is! Map) { - final s = raw.toString().trim(); - return s.isEmpty ? null : s; - } - final map = raw; - if (map[key] != null) { - return map[key].toString().trim().isEmpty - ? null - : map[key].toString().trim(); - } - for (final k in ['data', 'value']) { - final val = map[k]; - if (val == null) continue; - if (val is! Map) { - return val.toString().trim().isEmpty ? null : val.toString().trim(); - } - final nested = val; - if (nested[key] != null) { - return nested[key].toString().trim().isEmpty - ? null - : nested[key].toString().trim(); - } - } - return null; - } - - var semester = extract(semRes.data, 'default_semester'); - var year = extract(yearRes.data, 'default_academic_year'); - - AppLogger.i( - 'AuthNotifier: EzyGo academic context — semester=$semester year=$year', - ); - - // If EzyGo returned null/empty, calculate from date and POST back - if (semester == null || year == null) { - final fallback = calculateCurrentAcademicInfo(); - semester ??= fallback['current_semester']!; - year ??= fallback['current_year']!; - - AppLogger.i( - 'AuthNotifier: EzyGo academic context missing — using fallback ($semester, $year) and posting back', - ); - - // Best-effort POST — do not throw if this fails - try { - await Future.wait([ - api.updateSemester(semester, storage), - api.updateAcademicYear(year, storage), - ]); - } on Object catch (e) { - AppLogger.w( - 'AuthNotifier: Could not POST fallback academic context to EzyGo', - e, - ); - } - } - - final academicState = AcademicState(semester: semester, year: year); - await storage.saveAcademicState(academicState); - AppLogger.i('AuthNotifier: Academic context saved — $semester / $year'); - } on Object catch (e) { - // EzyGo is down: calculate fallback and persist it so the app still works - AppLogger.w( - 'AuthNotifier: Failed to fetch academic context from EzyGo. Using date-based fallback.', - e, - ); - final fallback = calculateCurrentAcademicInfo(); - final academicState = AcademicState( - semester: fallback['current_semester']!, - year: fallback['current_year']!, - ); - await storage.saveAcademicState(academicState); - } - } - Future login(String username, String password) async { ref.invalidate(institutionsProvider); state = const AsyncValue.loading(); @@ -686,14 +592,12 @@ class AuthNotifier extends AsyncNotifier final token = await _getFreshSupabaseToken(); if (token != null) { - // Fetch sem/year from EzyGo (blocking — user waits on login screen) - await _fetchAndSaveAcademicContext(token); - - // Mark as syncing and kick off cron sync in background + // Mark as syncing and trigger backend full EzyGo sync final profiledUser = await _fetchAndApplyServerProfile( cachedUser, supabaseToken: token, updateState: false, + sync: true, // Wait for backend to heal semester and fetch courses ); final syncingUser = profiledUser.copyWith(isSyncing: true); state = AsyncValue.data(syncingUser); @@ -703,10 +607,9 @@ class AuthNotifier extends AsyncNotifier // Pre-fetch institutions so they are ready in settings unawaited(ref.read(institutionsProvider.future)); - await ref - .read(apiServiceProvider) - .triggerSync(token, force: true); - await refreshProfile(force: true); + unawaited( + ref.read(apiServiceProvider).triggerSync(token, force: true), + ); } on Object catch (e) { AppLogger.w('AuthNotifier: Post-login cron sync failed', e); } finally { @@ -745,7 +648,9 @@ class AuthNotifier extends AsyncNotifier ref.read(securityFailureProvider.notifier).clearFailure(); } ref.read(apiServiceProvider).clearCaches(); - ref.invalidate(institutionsProvider); + ref + ..invalidate(institutionsProvider) + ..invalidate(academicProvider); state = const AsyncValue.data(null); try { @@ -910,9 +815,7 @@ class AuthNotifier extends AsyncNotifier await storage.saveAcademicState(nextAcademic); } - // 3. Trigger server-side profile sync to align database with new Ezygo state - // This also updates our local profile and settings via _applyProfileResponseData. - // We set isSyncing=true here to show the syncing overlay while backend sync runs. + // 3. Set isSyncing=true here to show the syncing overlay while backend sync runs. final syncingUser = user.copyWith(isSyncing: true); state = AsyncValue.data(syncingUser); @@ -922,49 +825,47 @@ class AuthNotifier extends AsyncNotifier return; } - // Fetch profile with sync=true to force database to sync with Ezygo and get the new class label first (blocking). - final response = await api.refreshProfile(token, sync: true); - if (response.statusCode == 401) { - final data = response.data as Map?; - throw AppException( - message: formatApiError(data, 'Security Verification'), - type: AppExceptionType.unauthorized, - statusCode: 401, - details: data, + // Sequential Clean Sync Flow: + try { + api.clearCaches(); + + // 1. Fetch the fresh profile from Supabase with sync: true + // This forces the backend to fetch the NEW courses for the updated semester and populate the database! + final response = await api.refreshProfile( + token, + sync: true, + force: true, ); - } + if (response.statusCode == 401) { + final data = response.data as Map?; + throw AppException( + message: formatApiError(data, 'Security Verification'), + type: AppExceptionType.unauthorized, + statusCode: 401, + details: data, + ); + } - if (response.statusCode != 200 || response.data == null) { - if (response.statusCode != null && response.statusCode! >= 500) { + if (response.statusCode != 200 || response.data == null) { + if (response.statusCode != null && response.statusCode! >= 500) { + throw const AppException( + message: 'Ezygo issues (5xx)', + type: AppExceptionType.server, + ); + } throw const AppException( - message: 'Ezygo issues (5xx)', + message: 'Profile sync failed', type: AppExceptionType.server, ); } - throw const AppException( - message: 'Profile sync failed', - type: AppExceptionType.server, - ); - } - final updatedUser = await _applyProfileResponseData( - currentUser: syncingUser, - data: response.data as Map, - ); + // 2. Trigger the backend cron sync in the background (non-blocking) + unawaited(api.triggerSync(token, force: true)); - // 4. Perform the remaining backend queries blocking (not ezygo ones) - try { - api.clearCaches(); - await api.triggerSync(token, force: true); - - // Fetch final profile (blocking) - final finalResponse = await api.refreshProfile(token); - if (finalResponse.statusCode == 200 && finalResponse.data != null) { - await _applyProfileResponseData( - currentUser: updatedUser, - data: finalResponse.data as Map, - ); - } + await _applyProfileResponseData( + currentUser: syncingUser, + data: response.data as Map, + ); } finally { final finalUser = state.value; if (finalUser != null) { @@ -1077,6 +978,7 @@ class AuthNotifier extends AsyncNotifier String? supabaseToken, bool updateState = true, bool sync = false, + bool force = false, }) async { final token = supabaseToken ?? await _getFreshSupabaseToken(); if (token == null) { @@ -1087,7 +989,7 @@ class AuthNotifier extends AsyncNotifier } final api = ref.read(apiServiceProvider); - final response = await api.refreshProfile(token, sync: sync); + final response = await api.refreshProfile(token, sync: sync, force: force); if (response.statusCode == 401) { final data = response.data as Map?; @@ -1122,25 +1024,6 @@ class AuthNotifier extends AsyncNotifier state = AsyncValue.data(updatedUser); } - if (sync) { - unawaited( - Future.microtask(() async { - try { - api.clearCaches(); - await api.triggerSync(token, force: true); - await refreshProfile(force: true); - } on Object catch (e) { - AppLogger.w('AuthNotifier: Profile-triggered cron sync failed', e); - } finally { - final finalUser = state.value; - if (finalUser != null) { - state = AsyncValue.data(finalUser.copyWith(isSyncing: false)); - } - } - }), - ); - } - return updatedUser; } @@ -1186,6 +1069,14 @@ class AuthNotifier extends AsyncNotifier username: data['username'] as String? ?? currentUser.username, ); + final nextAcademic = + (data['current_semester'] != null && data['current_year'] != null) + ? AcademicState( + semester: data['current_semester']! as String, + year: data['current_year']! as String, + ) + : null; + await Future.wait([ storage.saveEzygoToken(mergedUser.ezygoToken.value), storage.saveSupabaseUserId(mergedUser.supabaseUserId), @@ -1197,8 +1088,50 @@ class AuthNotifier extends AsyncNotifier storage.saveUsername(mergedUser.username!), if (mergedUser.termsVersion != null) storage.saveTermsVersion(mergedUser.termsVersion!), + if (nextAcademic != null) storage.saveAcademicState(nextAcademic), ]); + final academicChanged = + nextAcademic != null && + (currentUser.profile?.currentSemester != nextAcademic.semester || + currentUser.profile?.currentYear != nextAcademic.year); + + 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.w( + 'AuthNotifier: Failed to update EzyGo active semester during self-heal', + e, + ); + } + + // Clear all page caches/providers to force clean re-fetch of all pages + ref + ..invalidate(dashboardProvider) + ..invalidate(trackingProvider) + ..invalidate(scoreProvider) + ..invalidate(leaveProvider); + } + + if (nextAcademic != null) { + unawaited( + Future.delayed(Duration.zero, () { + ref.read(academicProvider.notifier).state = AsyncValue.data( + nextAcademic, + ); + }), + ); + } + if (updateState) state = AsyncValue.data(mergedUser); _lastRefresh = DateTime.now(); return mergedUser; diff --git a/mobile/lib/providers/dashboard_provider.dart b/mobile/lib/providers/dashboard_provider.dart index a2011425..68c762ea 100644 --- a/mobile/lib/providers/dashboard_provider.dart +++ b/mobile/lib/providers/dashboard_provider.dart @@ -49,6 +49,8 @@ class DashboardNotifier extends AsyncNotifier { List? _cachedInstructors; AcademicState? _lastAcademic; + bool _needsRevalidate = true; + @override FutureOr build() async { // 1. Reactive Dependency: Rebuild when auth user ID or academic status changes @@ -70,24 +72,10 @@ class DashboardNotifier extends AsyncNotifier { _cachedInstructors = null; _lastAcademic = null; _lastClassId = null; + _needsRevalidate = true; throw Exception('Not authenticated'); } - // BLOCKER: Do not fire queries until Cron Sync is finished - if (user.isSyncing) { - if (_cachedCourses != null && _cachedAttendance != null) { - return _processData( - _cachedCourses!, - _cachedAttendance!, - [], - academic, - _cachedInstructors ?? [], - ); - } - // Return a future that will be replaced once isSyncing changes - return Completer().future; - } - // Invalidate caches when account switches. if (_lastUserId != null && _lastUserId != user.supabaseUserId) { _cachedCourses = null; @@ -95,6 +83,7 @@ class DashboardNotifier extends AsyncNotifier { _cachedInstructors = null; _lastAcademic = null; _lastClassId = null; + _needsRevalidate = true; } _lastUserId = user.supabaseUserId; @@ -105,6 +94,7 @@ class DashboardNotifier extends AsyncNotifier { _cachedAttendance = null; _cachedInstructors = null; _lastAcademic = null; + _needsRevalidate = true; } _lastClassId = classId; @@ -113,10 +103,62 @@ class DashboardNotifier extends AsyncNotifier { _cachedCourses = null; _cachedAttendance = null; _cachedInstructors = null; - _lastClassId = null; + _needsRevalidate = true; } _lastAcademic = academic; + // Load Disk Cache (Secure Storage) if in-memory cache is empty + final storage = ref.read(secureStorageProvider); + final cacheKeySuffix = + '${user.supabaseUserId}_${academic.semester}_${academic.year}'; + if (_cachedCourses == null || _cachedAttendance == null) { + try { + final results = await Future.wait([ + storage.getCachedData('dashboard_courses_$cacheKeySuffix'), + storage.getCachedData('dashboard_attendance_$cacheKeySuffix'), + storage.getCachedData('dashboard_instructors_$cacheKeySuffix'), + ]); + final cachedCoursesRaw = results[0]; + final cachedAttendanceRaw = results[1]; + final cachedInstructorsRaw = results[2]; + + if (cachedCoursesRaw != null && cachedAttendanceRaw != null) { + _cachedCourses = (cachedCoursesRaw as List) + .map((c) => CourseDetails.fromJson(c as Map)) + .toList(); + _cachedAttendance = AttendanceReportDetailed.fromJson( + cachedAttendanceRaw as Map, + ); + if (cachedInstructorsRaw != null) { + _cachedInstructors = (cachedInstructorsRaw as List) + .map( + (i) => CourseInstructor.fromJson(i as Map), + ) + .toList(); + } else { + _cachedInstructors = []; + } + } + } on Object catch (e) { + AppLogger.w('DashboardNotifier: Error loading disk cache', e); + } + } + + // BLOCKER: Do not fire server queries until Cron Sync is finished + if (user.isSyncing) { + if (_cachedCourses != null && _cachedAttendance != null) { + return _processData( + _cachedCourses!, + _cachedAttendance!, + [], + academic, + _cachedInstructors ?? [], + ); + } + // Return a future that will be replaced once isSyncing changes + return Completer().future; + } + // 2. Wait for tracking data final tracking = await ref.watch(trackingProvider.future); @@ -129,12 +171,17 @@ class DashboardNotifier extends AsyncNotifier { .expand((e) => e) .toList(); - // 2.5 Logic moved to NavigationShell for centralized app-startup sync. - // 3. Fast Path: If we have cached official data AND the term hasn't changed if (_cachedCourses != null && _cachedAttendance != null && _lastAcademic == academic) { + if (_needsRevalidate) { + _needsRevalidate = false; + unawaited( + Future.microtask(() => _silentRevalidate(trackingList, academic)), + ); + } + return _processData( _cachedCourses!, _cachedAttendance!, @@ -144,6 +191,7 @@ class DashboardNotifier extends AsyncNotifier { ); } + _needsRevalidate = false; return _fetchAndProcess(trackingList, academic, tracking.officialReport); } @@ -248,6 +296,30 @@ class DashboardNotifier extends AsyncNotifier { _cachedAttendance = _mergeAttendanceCourses(attendance, sharedCourses); _cachedInstructors = sharedInstructors; + final user = ref.read(authProvider).value; + if (user != null) { + final cacheKeySuffix = + '${user.supabaseUserId}_${academic.semester}_${academic.year}'; + unawaited( + storage.saveCachedData( + 'dashboard_courses_$cacheKeySuffix', + _cachedCourses!.map((c) => c.toJson()).toList(), + ), + ); + unawaited( + storage.saveCachedData( + 'dashboard_attendance_$cacheKeySuffix', + _cachedAttendance!.toJson(), + ), + ); + unawaited( + storage.saveCachedData( + 'dashboard_instructors_$cacheKeySuffix', + _cachedInstructors!.map((i) => i.toJson()).toList(), + ), + ); + } + return _processData( _cachedCourses!, _cachedAttendance!, @@ -435,6 +507,21 @@ class DashboardNotifier extends AsyncNotifier { ); } + Future _silentRevalidate( + List tracking, + AcademicState academic, + ) async { + try { + final freshData = await _fetchAndProcess(tracking, academic, null); + state = AsyncValue.data(freshData); + } on Object catch (e) { + AppLogger.w( + 'DashboardNotifier: Silent background revalidation failed', + e, + ); + } + } + Future refresh() async { ref.invalidate(notificationsProvider); final user = ref.read(authProvider).value; @@ -454,9 +541,21 @@ class DashboardNotifier extends AsyncNotifier { await api.triggerSync(supabaseToken, force: true); // 2. Fetch the fresh profile from the server - await ref - .read(authProvider.notifier) - .refreshProfile(force: true, sync: true); + await ref.read(authProvider.notifier).refreshProfile(force: true); + + // Clear disk cache for current user/term so refresh is guaranteed fresh + final storage = ref.read(secureStorageProvider); + final academicAsync = ref.read(academicProvider); + final academic = academicAsync.value; + if (academic != null) { + final suffix = + '${user.supabaseUserId}_${academic.semester}_${academic.year}'; + await Future.wait([ + storage.deleteCachedData('dashboard_courses_$suffix'), + storage.deleteCachedData('dashboard_attendance_$suffix'), + storage.deleteCachedData('dashboard_instructors_$suffix'), + ]); + } } // 3. Refresh Tracking (Official Report + Tracker Records) @@ -469,6 +568,7 @@ class DashboardNotifier extends AsyncNotifier { _cachedAttendance = null; _cachedInstructors = null; _lastAcademic = null; + _needsRevalidate = true; state = await AsyncValue.guard(() async { final academicAsync = ref.read(academicProvider); diff --git a/mobile/lib/providers/score_provider.dart b/mobile/lib/providers/score_provider.dart index a2e063ef..6195f319 100644 --- a/mobile/lib/providers/score_provider.dart +++ b/mobile/lib/providers/score_provider.dart @@ -236,8 +236,12 @@ class ScoreNotifier extends AsyncNotifier { final cacheKeyQs = 'exam_questions_${exam.id}'; final cacheKeyAns = 'exam_answers_${exam.id}'; - dynamic qsData = await storage.getCachedData(cacheKeyQs); - dynamic ansData = await storage.getCachedData(cacheKeyAns); + final cacheResults = await Future.wait([ + storage.getCachedData(cacheKeyQs), + storage.getCachedData(cacheKeyAns), + ]); + dynamic qsData = cacheResults[0]; + dynamic ansData = cacheResults[1]; if (qsData == null || ansData == null) { final results = await Future.wait([ diff --git a/mobile/lib/screens/about_screen.dart b/mobile/lib/screens/about_screen.dart index 54623e9c..3a572067 100644 --- a/mobile/lib/screens/about_screen.dart +++ b/mobile/lib/screens/about_screen.dart @@ -10,7 +10,7 @@ import 'package:ghostclass/widgets/about/attestation_section.dart'; import 'package:ghostclass/widgets/transparency_badge.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:url_launcher/url_launcher.dart'; class AboutScreen extends ConsumerWidget { @@ -89,7 +89,7 @@ class AboutScreen extends ConsumerWidget { ), const Spacer(), _PillButton( - icon: LucideIcons.github, + icon: LucideIcons.gitBranch, label: 'Repo', onTap: () { final _ = _launchUrl(AppConfig.githubUrl); @@ -301,7 +301,7 @@ class AboutScreen extends ConsumerWidget { }, ), LinkRow( - icon: LucideIcons.github, + icon: LucideIcons.gitBranch, title: 'GitHub repository', value: AppConfig.githubUrl, onTap: () { diff --git a/mobile/lib/screens/accept_terms_screen.dart b/mobile/lib/screens/accept_terms_screen.dart index 0ee57055..af397df5 100644 --- a/mobile/lib/screens/accept_terms_screen.dart +++ b/mobile/lib/screens/accept_terms_screen.dart @@ -8,7 +8,7 @@ import 'package:ghostclass/services/logger.dart'; import 'package:ghostclass/widgets/loading_overlay.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class AcceptTermsScreen extends ConsumerStatefulWidget { const AcceptTermsScreen({super.key}); diff --git a/mobile/lib/screens/attendance_calendar_screen.dart b/mobile/lib/screens/attendance_calendar_screen.dart index c26ba063..6d720aeb 100644 --- a/mobile/lib/screens/attendance_calendar_screen.dart +++ b/mobile/lib/screens/attendance_calendar_screen.dart @@ -124,7 +124,7 @@ class _AttendanceCalendarScreenState ref.read(dashboardProvider.future), ref.read(trackingProvider.future), ref.read(academicProvider.future), - ]); + ]).timeout(const Duration(seconds: 10)); } on Object catch (e, st) { AppLogger.e('AttendanceCalendarScreen: Retry failed', e, st); } diff --git a/mobile/lib/screens/contact_screen.dart b/mobile/lib/screens/contact_screen.dart index 31977e86..1c7751d5 100644 --- a/mobile/lib/screens/contact_screen.dart +++ b/mobile/lib/screens/contact_screen.dart @@ -5,7 +5,7 @@ import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/logger.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; class ContactScreen extends ConsumerStatefulWidget { diff --git a/mobile/lib/screens/dashboard_screen.dart b/mobile/lib/screens/dashboard_screen.dart index 76302802..51ed6ba4 100644 --- a/mobile/lib/screens/dashboard_screen.dart +++ b/mobile/lib/screens/dashboard_screen.dart @@ -47,7 +47,9 @@ class _DashboardScreenState extends ConsumerState { ..invalidate(dashboardProvider) ..invalidate(institutionsProvider); try { - await ref.read(dashboardProvider.future); + await ref + .read(dashboardProvider.future) + .timeout(const Duration(seconds: 10)); } on Object catch (e, st) { AppLogger.e('DashboardScreen: Retry failed', e, st); } diff --git a/mobile/lib/screens/ghostclass_screen.dart b/mobile/lib/screens/ghostclass_screen.dart index 6834ae2a..50bcb2dc 100644 --- a/mobile/lib/screens/ghostclass_screen.dart +++ b/mobile/lib/screens/ghostclass_screen.dart @@ -13,7 +13,7 @@ import 'package:ghostclass/widgets/ghostclass/ghostclass_settings_card.dart'; import 'package:ghostclass/widgets/loading_overlay.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; /// GhostClassScreen /// ---------------- diff --git a/mobile/lib/screens/help_screen.dart b/mobile/lib/screens/help_screen.dart index 8c269e64..b9cefc09 100644 --- a/mobile/lib/screens/help_screen.dart +++ b/mobile/lib/screens/help_screen.dart @@ -3,7 +3,7 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class HelpScreen extends StatelessWidget { const HelpScreen({super.key}); diff --git a/mobile/lib/screens/leaves_screen.dart b/mobile/lib/screens/leaves_screen.dart index af99525f..4bd46762 100644 --- a/mobile/lib/screens/leaves_screen.dart +++ b/mobile/lib/screens/leaves_screen.dart @@ -8,7 +8,7 @@ import 'package:ghostclass/widgets/service_error_view.dart'; import 'package:ghostclass/widgets/service_refresh_indicator.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:intl/intl.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class LeavesScreen extends ConsumerWidget { const LeavesScreen({super.key}); diff --git a/mobile/lib/screens/legal_screen.dart b/mobile/lib/screens/legal_screen.dart index 6a2f9f03..81a3ae22 100644 --- a/mobile/lib/screens/legal_screen.dart +++ b/mobile/lib/screens/legal_screen.dart @@ -8,7 +8,7 @@ import 'package:ghostclass/constants/static_content.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:url_launcher/url_launcher.dart'; class LegalScreen extends StatefulWidget { diff --git a/mobile/lib/screens/login_screen.dart b/mobile/lib/screens/login_screen.dart index 7773d2d0..39d5e73d 100644 --- a/mobile/lib/screens/login_screen.dart +++ b/mobile/lib/screens/login_screen.dart @@ -17,7 +17,7 @@ import 'package:ghostclass/widgets/app_update_dialog.dart'; import 'package:ghostclass/widgets/loading_overlay.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:url_launcher/url_launcher.dart'; class LoginScreen extends ConsumerStatefulWidget { diff --git a/mobile/lib/screens/navigation_shell.dart b/mobile/lib/screens/navigation_shell.dart index 1d5bbd41..c3fa0993 100644 --- a/mobile/lib/screens/navigation_shell.dart +++ b/mobile/lib/screens/navigation_shell.dart @@ -26,7 +26,7 @@ import 'package:ghostclass/widgets/app_update_dialog.dart'; import 'package:ghostclass/widgets/service_error_view.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class NavigationShell extends ConsumerStatefulWidget { const NavigationShell({required this.child, super.key}); @@ -594,7 +594,7 @@ class _NavigationShellState extends ConsumerState { await Future.wait([ ref.read(dashboardProvider.future), ref.read(trackingProvider.future), - ]); + ]).timeout(const Duration(seconds: 10)); AppLogger.i( 'NavigationShell: Outage recovery wait completed (or partial success).', ); diff --git a/mobile/lib/screens/notifications_screen.dart b/mobile/lib/screens/notifications_screen.dart index f04f4e66..578c9721 100644 --- a/mobile/lib/screens/notifications_screen.dart +++ b/mobile/lib/screens/notifications_screen.dart @@ -7,7 +7,7 @@ import 'package:ghostclass/widgets/service_refresh_indicator.dart'; import 'package:ghostclass/widgets/service_toast.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:intl/intl.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:permission_handler/permission_handler.dart'; // The exact Riverpod 3.x auto-dispose future provider type is an internal diff --git a/mobile/lib/screens/profile_dump_screen.dart b/mobile/lib/screens/profile_dump_screen.dart index 31b37f63..ea033d59 100644 --- a/mobile/lib/screens/profile_dump_screen.dart +++ b/mobile/lib/screens/profile_dump_screen.dart @@ -9,7 +9,7 @@ import 'package:ghostclass/services/logger.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class ProfileDumpScreen extends ConsumerWidget { const ProfileDumpScreen({super.key}); diff --git a/mobile/lib/screens/profile_screen.dart b/mobile/lib/screens/profile_screen.dart index e943c575..5058972c 100644 --- a/mobile/lib/screens/profile_screen.dart +++ b/mobile/lib/screens/profile_screen.dart @@ -13,7 +13,7 @@ import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:image_picker/image_picker.dart'; import 'package:intl/intl.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:path/path.dart' as path; import 'package:supabase_flutter/supabase_flutter.dart'; diff --git a/mobile/lib/screens/scores_screen.dart b/mobile/lib/screens/scores_screen.dart index 90699904..8909d522 100644 --- a/mobile/lib/screens/scores_screen.dart +++ b/mobile/lib/screens/scores_screen.dart @@ -9,7 +9,7 @@ import 'package:ghostclass/widgets/loading_overlay.dart'; import 'package:ghostclass/widgets/service_refresh_indicator.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:intl/intl.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class ScoresScreen extends ConsumerStatefulWidget { const ScoresScreen({super.key}); diff --git a/mobile/lib/screens/splash_screen.dart b/mobile/lib/screens/splash_screen.dart index 7ebb24a7..3b06ee87 100644 --- a/mobile/lib/screens/splash_screen.dart +++ b/mobile/lib/screens/splash_screen.dart @@ -15,6 +15,7 @@ import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/jwe_service.dart'; import 'package:ghostclass/services/logger.dart'; +import 'package:ghostclass/services/security_service.dart'; import 'package:ghostclass/widgets/app_update_dialog.dart'; import 'package:ghostclass/widgets/service_error_dialog.dart'; import 'package:go_router/go_router.dart'; @@ -30,7 +31,9 @@ class _SplashScreenState extends ConsumerState { @override void initState() { super.initState(); - final _ = _initializeApp(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final _ = _initializeApp(); + }); } Future _initializeApp() async { @@ -42,14 +45,46 @@ class _SplashScreenState extends ConsumerState { try { final api = ref.read(apiServiceProvider); - // Perform full server-side integrity check before anything else - // This satisfies the requirement: "all other reqs must be only if app check success" - final versionResult = await api.verifyIntegrity(); + AppLogger.i('SplashScreen: Starting parallel initialization tasks...'); + + // Start device attestation and auth profile sync in parallel! + final integrityTask = api.verifyIntegrity().then((res) { + AppLogger.i('SplashScreen: integrityTask completed'); + return res; + }); + final authTask = ref.read(authProvider.future).then((res) { + AppLogger.i('SplashScreen: authTask completed'); + return res; + }); + + // CLEAR ALL previous app-open caches for a truly fresh start + api.clearCaches(); + + AppLogger.i('SplashScreen: Awaiting Future.wait...'); + // Wait for both critical security attestation & profile sync to resolve successfully + final results = await Future.wait([ + integrityTask, + authTask, + jwePreWarm.then((_) { + AppLogger.i('SplashScreen: jwePreWarm completed'); + }), + apiPreWarm.then((_) { + AppLogger.i('SplashScreen: apiPreWarm completed'); + }), + Future.delayed(const Duration(milliseconds: 3000)).then((_) { + AppLogger.i('SplashScreen: 3s delay completed'); + }), + ]); + + AppLogger.i('SplashScreen: Future.wait completed successfully'); + + final versionResult = results[0] as AppVersionCheckResult?; if (versionResult != null && versionResult.hasUpdate) { ref.read(appUpdateProvider.notifier).setCheckResult(versionResult); if (versionResult.isForceUpdate) { + AppLogger.w('SplashScreen: Force update required!'); if (!mounted) return; await AppUpdateDialog.show( context, @@ -61,40 +96,25 @@ class _SplashScreenState extends ConsumerState { } } - // 3. Start hydration only after security is verified - // CLEAR ALL previous app-open caches for a truly fresh start - api.clearCaches(); - - final authTask = ref.read(authProvider.future); - unawaited( - authTask.then((user) { - if (!mounted || user == null || user.profile?.avatarUrl == null) { - return; - } - try { - final _ = precacheImage( - NetworkImage( - user.profile!.avatarUrl!, - headers: { - 'Origin': AppConfig.supabaseOrigin, - }, - ), - context, - ); - } on Object catch (e, st) { - AppLogger.e('SplashScreen: Avatar pre-cache failed', e, st); - } - }), + final user = results[1] as AuthenticatedUser?; + AppLogger.i( + 'SplashScreen: Initialized user: ${user?.supabaseUserId ?? "null"} (syncing: ${user?.isSyncing ?? "false"})', ); - - await Future.wait([ - Future.delayed( - const Duration(milliseconds: 2500), - ), // Keep the splash visible for at least 2.5 seconds. - jwePreWarm, - apiPreWarm, - authTask, - ]); + if (mounted && user != null && user.profile?.avatarUrl != null) { + try { + final _ = precacheImage( + NetworkImage( + user.profile!.avatarUrl!, + headers: { + 'Origin': AppConfig.supabaseOrigin, + }, + ), + context, + ); + } on Object catch (e, st) { + AppLogger.e('SplashScreen: Avatar pre-cache failed', e, st); + } + } } on Object catch (e) { AppLogger.e('SplashScreen: Initialization error', e); diff --git a/mobile/lib/screens/tracking_screen.dart b/mobile/lib/screens/tracking_screen.dart index b56f6e53..673670f0 100644 --- a/mobile/lib/screens/tracking_screen.dart +++ b/mobile/lib/screens/tracking_screen.dart @@ -24,7 +24,7 @@ import 'package:ghostclass/widgets/tracking/tracking_filter_chip.dart'; import 'package:ghostclass/widgets/tracking/tracking_header_widgets.dart'; import 'package:ghostclass/widgets/tracking/tracking_subject_picker.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class TrackingScreen extends ConsumerStatefulWidget { const TrackingScreen({super.key}); @@ -60,7 +60,9 @@ class _TrackingScreenState extends ConsumerState ref.read(apiServiceProvider).clearCaches(); ref.invalidate(trackingProvider); try { - await ref.read(trackingProvider.future); + await ref + .read(trackingProvider.future) + .timeout(const Duration(seconds: 10)); } on Object catch (e, st) { AppLogger.e('TrackingScreen: Retry failed', e, st); } diff --git a/mobile/lib/services/api_service.dart b/mobile/lib/services/api_service.dart index 7fd7959f..7482487d 100644 --- a/mobile/lib/services/api_service.dart +++ b/mobile/lib/services/api_service.dart @@ -60,7 +60,8 @@ class ApiService { Future> refreshProfile( String supabaseToken, { bool sync = false, - }) => _auth.refreshProfile(supabaseToken, sync: sync); + bool force = false, + }) => _auth.refreshProfile(supabaseToken, sync: sync, force: force); Future> syncMobileAuth(String supabaseToken) => _auth.syncMobileAuth(supabaseToken); diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index 917df92f..0a236728 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -64,10 +64,15 @@ class AuthService { Future> refreshProfile( String supabaseToken, { bool sync = false, + bool force = false, }) async { + final params = {}; + if (sync) params['sync'] = 'true'; + if (force) params['force'] = 'true'; + return _dio.get( '$_ghostclassBaseUrl/profile', - queryParameters: sync ? {'sync': 'true'} : null, + queryParameters: params.isNotEmpty ? params : null, options: Options( headers: {'Authorization': 'Bearer $supabaseToken'}, validateStatus: (s) => s != null && s < 600, diff --git a/mobile/lib/services/secure_storage.dart b/mobile/lib/services/secure_storage.dart index 10ddbbc4..d0e70cfc 100644 --- a/mobile/lib/services/secure_storage.dart +++ b/mobile/lib/services/secure_storage.dart @@ -208,6 +208,11 @@ class SecureStorageService { Future readSecure(String key) => _storage.read(key: key); + Future deleteSecure(String key) => _storage.delete(key: key); + + Future deleteCachedData(String key) => + _storage.delete(key: 'cache_$key'); + // ─── Full Clear ────────────────────────────────────────────────────────── /// Deletes every key managed by this service. Should be called on logout. diff --git a/mobile/lib/widgets/about/about_widgets.dart b/mobile/lib/widgets/about/about_widgets.dart index 3dd4769b..c8387577 100644 --- a/mobile/lib/widgets/about/about_widgets.dart +++ b/mobile/lib/widgets/about/about_widgets.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class MetricCard extends StatelessWidget { const MetricCard({ diff --git a/mobile/lib/widgets/about/attestation_section.dart b/mobile/lib/widgets/about/attestation_section.dart index 02bcdf60..1f9621ea 100644 --- a/mobile/lib/widgets/about/attestation_section.dart +++ b/mobile/lib/widgets/about/attestation_section.dart @@ -5,7 +5,7 @@ import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:ghostclass/widgets/about/about_widgets.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class AttestationSection extends ConsumerStatefulWidget { const AttestationSection({ diff --git a/mobile/lib/widgets/add_attendance_dialog.dart b/mobile/lib/widgets/add_attendance_dialog.dart index 27b0d33a..9be6e41a 100644 --- a/mobile/lib/widgets/add_attendance_dialog.dart +++ b/mobile/lib/widgets/add_attendance_dialog.dart @@ -15,7 +15,7 @@ import 'package:ghostclass/widgets/attendance/attendance_dialog_widgets.dart'; import 'package:ghostclass/widgets/service_toast.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:intl/intl.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class AddAttendanceDialog extends ConsumerStatefulWidget { const AddAttendanceDialog({super.key}); diff --git a/mobile/lib/widgets/aesthetic_refresh_indicator.dart b/mobile/lib/widgets/aesthetic_refresh_indicator.dart index 0ae59b75..7843dd17 100644 --- a/mobile/lib/widgets/aesthetic_refresh_indicator.dart +++ b/mobile/lib/widgets/aesthetic_refresh_indicator.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:ghostclass/services/logger.dart'; import 'package:ghostclass/widgets/loading_overlay.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; /// AestheticRefreshIndicator /// ------------------------- diff --git a/mobile/lib/widgets/app_footer.dart b/mobile/lib/widgets/app_footer.dart index 7595d8dc..ae389c88 100644 --- a/mobile/lib/widgets/app_footer.dart +++ b/mobile/lib/widgets/app_footer.dart @@ -3,7 +3,7 @@ import 'package:ghostclass/config/app_config.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:url_launcher/url_launcher.dart'; class AppFooter extends StatelessWidget { diff --git a/mobile/lib/widgets/app_update_dialog.dart b/mobile/lib/widgets/app_update_dialog.dart index 6bff39b9..b73a60e9 100644 --- a/mobile/lib/widgets/app_update_dialog.dart +++ b/mobile/lib/widgets/app_update_dialog.dart @@ -5,7 +5,7 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:ghostclass/config/app_config.dart'; import 'package:ghostclass/logic/support_helper.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:url_launcher/url_launcher.dart'; class AppUpdateDialog extends StatelessWidget { diff --git a/mobile/lib/widgets/attendance/add_course_dialog.dart b/mobile/lib/widgets/attendance/add_course_dialog.dart index 4fa55693..f2079cff 100644 --- a/mobile/lib/widgets/attendance/add_course_dialog.dart +++ b/mobile/lib/widgets/attendance/add_course_dialog.dart @@ -4,7 +4,7 @@ import 'package:ghostclass/providers/dashboard_provider.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/widgets/service_toast.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:supabase_flutter/supabase_flutter.dart' as supabase; class AddCourseDialog extends ConsumerStatefulWidget { diff --git a/mobile/lib/widgets/attendance/edit_instructor_dialog.dart b/mobile/lib/widgets/attendance/edit_instructor_dialog.dart index 5dfd41ff..0e69bd90 100644 --- a/mobile/lib/widgets/attendance/edit_instructor_dialog.dart +++ b/mobile/lib/widgets/attendance/edit_instructor_dialog.dart @@ -7,7 +7,7 @@ import 'package:ghostclass/providers/dashboard_provider.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/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:supabase_flutter/supabase_flutter.dart' as supabase; class EditInstructorDialog extends ConsumerStatefulWidget { diff --git a/mobile/lib/widgets/calendar/calendar_day_details.dart b/mobile/lib/widgets/calendar/calendar_day_details.dart index 5c801161..ac921049 100644 --- a/mobile/lib/widgets/calendar/calendar_day_details.dart +++ b/mobile/lib/widgets/calendar/calendar_day_details.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:intl/intl.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class SelectedDayHeader extends StatelessWidget { const SelectedDayHeader({ diff --git a/mobile/lib/widgets/calendar/calendar_header.dart b/mobile/lib/widgets/calendar/calendar_header.dart index 101e3467..cdddb8ab 100644 --- a/mobile/lib/widgets/calendar/calendar_header.dart +++ b/mobile/lib/widgets/calendar/calendar_header.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:intl/intl.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class CalendarHeader extends StatelessWidget { const CalendarHeader({ diff --git a/mobile/lib/widgets/calendar/calendar_session_card.dart b/mobile/lib/widgets/calendar/calendar_session_card.dart index fee67a2b..b8d9f74b 100644 --- a/mobile/lib/widgets/calendar/calendar_session_card.dart +++ b/mobile/lib/widgets/calendar/calendar_session_card.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:ghostclass/logic/attendance_utils.dart' as utils; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class CalendarEvent { const CalendarEvent({ diff --git a/mobile/lib/widgets/dashboard/course_card.dart b/mobile/lib/widgets/dashboard/course_card.dart index 7c992821..de74503d 100644 --- a/mobile/lib/widgets/dashboard/course_card.dart +++ b/mobile/lib/widgets/dashboard/course_card.dart @@ -6,7 +6,7 @@ import 'package:ghostclass/models/dashboard_stats.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:ghostclass/widgets/attendance/edit_instructor_dialog.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class CourseCard extends StatelessWidget { const CourseCard({ diff --git a/mobile/lib/widgets/dashboard/course_card_widgets.dart b/mobile/lib/widgets/dashboard/course_card_widgets.dart index e78f850e..af64e8d9 100644 --- a/mobile/lib/widgets/dashboard/course_card_widgets.dart +++ b/mobile/lib/widgets/dashboard/course_card_widgets.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:ghostclass/logic/attendance_utils.dart' as utils; import 'package:ghostclass/theme/app_theme.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class StatBox extends StatelessWidget { const StatBox({ diff --git a/mobile/lib/widgets/dashboard/course_list_section.dart b/mobile/lib/widgets/dashboard/course_list_section.dart index 272759a6..32d9e9ab 100644 --- a/mobile/lib/widgets/dashboard/course_list_section.dart +++ b/mobile/lib/widgets/dashboard/course_list_section.dart @@ -6,7 +6,7 @@ import 'package:ghostclass/models/dashboard_stats.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'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class CourseLineupHeader extends StatelessWidget { const CourseLineupHeader({super.key}); diff --git a/mobile/lib/widgets/dashboard/header_section.dart b/mobile/lib/widgets/dashboard/header_section.dart index 0752d784..f94c20ca 100644 --- a/mobile/lib/widgets/dashboard/header_section.dart +++ b/mobile/lib/widgets/dashboard/header_section.dart @@ -4,7 +4,7 @@ import 'package:ghostclass/providers/academic_provider.dart'; import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/providers/dashboard_provider.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class HeaderSection extends ConsumerWidget { const HeaderSection({required this.data, super.key}); diff --git a/mobile/lib/widgets/dashboard/stats_grid_section.dart b/mobile/lib/widgets/dashboard/stats_grid_section.dart index 440b5995..5d4b3ff2 100644 --- a/mobile/lib/widgets/dashboard/stats_grid_section.dart +++ b/mobile/lib/widgets/dashboard/stats_grid_section.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:ghostclass/models/dashboard_stats.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class StatsGridSection extends StatelessWidget { const StatsGridSection({ diff --git a/mobile/lib/widgets/ghostclass/ghostclass_branding.dart b/mobile/lib/widgets/ghostclass/ghostclass_branding.dart index 0427c34f..e7bde22f 100644 --- a/mobile/lib/widgets/ghostclass/ghostclass_branding.dart +++ b/mobile/lib/widgets/ghostclass/ghostclass_branding.dart @@ -3,7 +3,7 @@ import 'package:ghostclass/config/app_config.dart'; import 'package:ghostclass/widgets/transparency_badge.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:url_launcher/url_launcher.dart'; class GhostClassBranding extends StatelessWidget { diff --git a/mobile/lib/widgets/loading_overlay.dart b/mobile/lib/widgets/loading_overlay.dart index 605f3722..06436607 100644 --- a/mobile/lib/widgets/loading_overlay.dart +++ b/mobile/lib/widgets/loading_overlay.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class LoadingOverlay extends StatelessWidget { const LoadingOverlay({ diff --git a/mobile/lib/widgets/profile/profile_widgets.dart b/mobile/lib/widgets/profile/profile_widgets.dart index ad0b4b64..839d5b00 100644 --- a/mobile/lib/widgets/profile/profile_widgets.dart +++ b/mobile/lib/widgets/profile/profile_widgets.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:ghostclass/config/app_config.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class ProfileHeader extends StatelessWidget { const ProfileHeader({ diff --git a/mobile/lib/widgets/service_error_dialog.dart b/mobile/lib/widgets/service_error_dialog.dart index 15d78195..03e5c14b 100644 --- a/mobile/lib/widgets/service_error_dialog.dart +++ b/mobile/lib/widgets/service_error_dialog.dart @@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class ServiceErrorDialog extends StatelessWidget { const ServiceErrorDialog({ @@ -212,36 +212,44 @@ class ServiceErrorDialog extends StatelessWidget { data: Theme.of( context, ).copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - title: Text( - 'Technical Details', - style: GoogleFonts.manrope( - fontSize: 12, - fontWeight: FontWeight.w700, - color: Theme.of(context).colorScheme.primary, - ), - ), - tilePadding: EdgeInsets.zero, - children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(12), + child: Material( + color: Colors.transparent, + child: ExpansionTile( + title: Text( + 'Technical Details', + style: GoogleFonts.manrope( + fontSize: 12, + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.primary, ), - child: SelectableText( - details!, - style: GoogleFonts.robotoMono( - fontSize: 10, - color: Theme.of(context).colorScheme.onSurface - .withValues(alpha: 0.6), + ), + tilePadding: EdgeInsets.zero, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: + Theme.of( + context, + ).colorScheme.onSurface.withValues( + alpha: 0.05, + ), + borderRadius: BorderRadius.circular(12), + ), + child: SelectableText( + details!, + style: GoogleFonts.robotoMono( + fontSize: 10, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + ), ), ), - ), - ], + ], + ), ), ), ], diff --git a/mobile/lib/widgets/service_error_view.dart b/mobile/lib/widgets/service_error_view.dart index 59d81479..427a8323 100644 --- a/mobile/lib/widgets/service_error_view.dart +++ b/mobile/lib/widgets/service_error_view.dart @@ -6,7 +6,7 @@ import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class ServiceErrorView extends ConsumerWidget { const ServiceErrorView({ diff --git a/mobile/lib/widgets/service_toast.dart b/mobile/lib/widgets/service_toast.dart index ed252663..a315760d 100644 --- a/mobile/lib/widgets/service_toast.dart +++ b/mobile/lib/widgets/service_toast.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class ServiceToast { static void show( diff --git a/mobile/lib/widgets/tracking/tracking_course_section.dart b/mobile/lib/widgets/tracking/tracking_course_section.dart index 6bbabc62..9fc8ca8c 100644 --- a/mobile/lib/widgets/tracking/tracking_course_section.dart +++ b/mobile/lib/widgets/tracking/tracking_course_section.dart @@ -5,7 +5,7 @@ import 'package:ghostclass/models/course_details.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:ghostclass/widgets/tracking/tracking_record_card.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class TrackingCourseSection extends StatelessWidget { const TrackingCourseSection({ diff --git a/mobile/lib/widgets/tracking/tracking_empty_state.dart b/mobile/lib/widgets/tracking/tracking_empty_state.dart index 1ab72618..c8de2ed1 100644 --- a/mobile/lib/widgets/tracking/tracking_empty_state.dart +++ b/mobile/lib/widgets/tracking/tracking_empty_state.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class EmptyTrackingState extends StatelessWidget { const EmptyTrackingState({super.key}); diff --git a/mobile/lib/widgets/tracking/tracking_filter_chip.dart b/mobile/lib/widgets/tracking/tracking_filter_chip.dart index e628ac78..a429a7b0 100644 --- a/mobile/lib/widgets/tracking/tracking_filter_chip.dart +++ b/mobile/lib/widgets/tracking/tracking_filter_chip.dart @@ -4,7 +4,7 @@ import 'package:ghostclass/models/attendance.dart'; import 'package:ghostclass/models/course_details.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class TrackingFilterChip extends StatelessWidget { const TrackingFilterChip({ diff --git a/mobile/lib/widgets/tracking/tracking_header_widgets.dart b/mobile/lib/widgets/tracking/tracking_header_widgets.dart index 77f5ab23..b79812dc 100644 --- a/mobile/lib/widgets/tracking/tracking_header_widgets.dart +++ b/mobile/lib/widgets/tracking/tracking_header_widgets.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class HeaderBadge extends StatelessWidget { const HeaderBadge({required this.count, super.key}); diff --git a/mobile/lib/widgets/tracking/tracking_record_card.dart b/mobile/lib/widgets/tracking/tracking_record_card.dart index 35128218..c5f92de4 100644 --- a/mobile/lib/widgets/tracking/tracking_record_card.dart +++ b/mobile/lib/widgets/tracking/tracking_record_card.dart @@ -4,7 +4,7 @@ import 'package:ghostclass/models/attendance.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:intl/intl.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class TrackingRecordCard extends StatelessWidget { const TrackingRecordCard({ diff --git a/mobile/lib/widgets/transparency_badge.dart b/mobile/lib/widgets/transparency_badge.dart index c70c1ca2..ad6c67e8 100644 --- a/mobile/lib/widgets/transparency_badge.dart +++ b/mobile/lib/widgets/transparency_badge.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; class TransparencyBadge extends StatelessWidget { const TransparencyBadge({super.key, this.onTap, this.expanded = false}); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 5e339898..3fc8ea44 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -928,14 +928,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" - lucide_icons: + lucide_icons_flutter: dependency: "direct main" description: - name: lucide_icons - sha256: ad24d0fd65707e48add30bebada7d90bff2a1bba0a72d6e9b19d44246b0e83c4 + name: lucide_icons_flutter + sha256: f9fd5d49b93bf14b89e0ff4818658a74ab16899bdaf7aa745358ee1a34f54eed url: "https://pub.dev" source: hosted - version: "0.257.0" + version: "3.1.14+1" markdown: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 4073292f..2f8beb44 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.5+1 +version: 4.3.6+1 environment: sdk: ^3.11.4 @@ -48,7 +48,7 @@ dependencies: image_picker: ^1.1.2 intl: ^0.20.2 jose: ^0.3.5+1 - lucide_icons: ^0.257.0 + lucide_icons_flutter: ^3.1.14+1 markdown: any package_info_plus: ^9.0.1 path: ^1.9.1 diff --git a/mobile/test/auto_coverage_booster_test.dart b/mobile/test/auto_coverage_booster_test.dart index 99721bc2..b5eeef42 100644 --- a/mobile/test/auto_coverage_booster_test.dart +++ b/mobile/test/auto_coverage_booster_test.dart @@ -170,6 +170,7 @@ void main() { await pumpScreen(const ProfileScreen()); await pumpScreen(const ScoresScreen()); await pumpScreen(const SplashScreen()); + await tester.pump(const Duration(seconds: 5)); await pumpScreen(const StaticPageScreen(title: 'Static')); await pumpScreen(const TrackingScreen()); }); diff --git a/mobile/test/coverage_booster_test.dart b/mobile/test/coverage_booster_test.dart index 1033f150..6ee7c218 100644 --- a/mobile/test/coverage_booster_test.dart +++ b/mobile/test/coverage_booster_test.dart @@ -16,6 +16,7 @@ import 'package:ghostclass/theme/app_theme.dart'; import 'package:ghostclass/widgets/service_error_dialog.dart'; import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'coverage_helper.dart'; @@ -200,6 +201,11 @@ void main() { }); testWidgets('Coverage Booster: AppRouter & SplashScreen', (tester) async { + SharedPreferences.setMockInitialValues({ + 'ghostclass_jwks_cache': '{"keys": []}', + 'ghostclass_jwks_time': DateTime.now().toIso8601String(), + }); + final mockSecurity = MockSecurityService(); when(mockSecurity.verifyIntegrity).thenAnswer( (_) async => AppVersionCheckResult( @@ -236,8 +242,9 @@ void main() { ), ); await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - await tester.pump(const Duration(milliseconds: 800)); + await tester.pump(const Duration(seconds: 3)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); // Verify dialog content is displayed on splash screen expect(find.text('Critical Update Required'), findsOneWidget); diff --git a/mobile/test/coverage_shallow_test.dart b/mobile/test/coverage_shallow_test.dart index 7a7f0173..11c5dee2 100644 --- a/mobile/test/coverage_shallow_test.dart +++ b/mobile/test/coverage_shallow_test.dart @@ -14,6 +14,7 @@ import 'package:ghostclass/widgets/add_attendance_dialog.dart'; import 'package:ghostclass/widgets/loading_overlay.dart'; import 'package:ghostclass/widgets/service_error_dialog.dart'; import 'package:ghostclass/widgets/tracking/tracking_subject_picker.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'coverage_helper.dart'; @@ -33,7 +34,13 @@ void main() { unreadCount: 2, ); - setUp(TestWidgetsFlutterBinding.ensureInitialized); + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({ + 'ghostclass_jwks_cache': '{"keys": []}', + 'ghostclass_jwks_time': DateTime.now().toIso8601String(), + }); + }); testWidgets('Shallow render important widgets', (tester) async { tester.view.physicalSize = const Size(800, 800); @@ -166,6 +173,7 @@ void main() { child: const MaterialApp(home: SplashScreen()), ), ); - await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(seconds: 3)); + await tester.pump(); }); } diff --git a/mobile/test/logic/network_utils_test.dart b/mobile/test/logic/network_utils_test.dart index 3eb8f38e..c3f05a80 100644 --- a/mobile/test/logic/network_utils_test.dart +++ b/mobile/test/logic/network_utils_test.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; +import 'package:ghostclass/config/app_config.dart'; import 'package:ghostclass/logic/network_utils.dart'; import 'package:mocktail/mocktail.dart'; @@ -29,11 +30,11 @@ void main() { }); test('validates hostname matching expected host exactly', () { - // In tests/dev mode, expected hostname defaults to localhost - when(() => mockCert.subject).thenReturn('CN=localhost,O=Test'); + final expectedHost = Uri.parse(AppConfig.ghostclassApiUrl).host; + when(() => mockCert.subject).thenReturn('CN=$expectedHost,O=Test'); final isValid = NetworkUtils.validateCertificateHostname( mockCert, - 'localhost', + expectedHost, 8080, ); expect(isValid, true); diff --git a/mobile/test/providers/auth_provider_analytics_test.dart b/mobile/test/providers/auth_provider_analytics_test.dart index c0bee9e1..cf6f6f02 100644 --- a/mobile/test/providers/auth_provider_analytics_test.dart +++ b/mobile/test/providers/auth_provider_analytics_test.dart @@ -437,7 +437,11 @@ void main() { var refreshCount = 0; when( - () => mockApi.refreshProfile(any(), sync: any(named: 'sync')), + () => mockApi.refreshProfile( + any(), + sync: any(named: 'sync'), + force: any(named: 'force'), + ), ).thenAnswer((invocation) async { refreshCount += 1; final sync = invocation.namedArguments[#sync] as bool? ?? false; @@ -464,12 +468,12 @@ void main() { verify(() => mockApi.clearCaches()).called(1); verify(() => mockApi.triggerSync(any(), force: true)).called(1); - expect(refreshCount, greaterThanOrEqualTo(2)); + expect(refreshCount, equals(1)); final user = container.read(authProvider).value; expect(user, isNotNull); expect(user!.isSyncing, isFalse); - expect(user.profile?.firstName, equals('Final')); + expect(user.profile?.firstName, equals('Synced')); }, ); @@ -517,7 +521,11 @@ void main() { ), ); when( - () => mockApi.refreshProfile(any(), sync: any(named: 'sync')), + () => mockApi.refreshProfile( + any(), + sync: any(named: 'sync'), + force: any(named: 'force'), + ), ).thenAnswer( (_) async => Response( requestOptions: RequestOptions(path: '/profile'), @@ -582,7 +590,11 @@ void main() { ), ); when( - () => mockApi.refreshProfile(any(), sync: any(named: 'sync')), + () => mockApi.refreshProfile( + any(), + sync: any(named: 'sync'), + force: any(named: 'force'), + ), ).thenAnswer( (_) async => Response( requestOptions: RequestOptions(path: '/profile'), diff --git a/mobile/test/services/push_notification_service_test.dart b/mobile/test/services/push_notification_service_test.dart index 6b1aad9f..e870e3bd 100644 --- a/mobile/test/services/push_notification_service_test.dart +++ b/mobile/test/services/push_notification_service_test.dart @@ -13,7 +13,7 @@ import 'package:ghostclass/services/dio_service.dart'; import 'package:ghostclass/services/push_notification_service.dart'; import 'package:ghostclass/services/secure_storage.dart'; import 'package:go_router/go_router.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:mocktail/mocktail.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; diff --git a/mobile/test/widgets/app_update_dialog_test.dart b/mobile/test/widgets/app_update_dialog_test.dart index 8998a6b8..5243a34c 100644 --- a/mobile/test/widgets/app_update_dialog_test.dart +++ b/mobile/test/widgets/app_update_dialog_test.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ghostclass/config/app_config.dart'; import 'package:ghostclass/widgets/app_update_dialog.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; void main() { testWidgets('AppUpdateDialog renders optional update layout correctly', ( diff --git a/mobile/test/widgets/service_toast_test.dart b/mobile/test/widgets/service_toast_test.dart index 3debfdc1..ab8525ed 100644 --- a/mobile/test/widgets/service_toast_test.dart +++ b/mobile/test/widgets/service_toast_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ghostclass/widgets/service_toast.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; void main() { testWidgets('ServiceToast.show displays message and handles dismissal', ( diff --git a/package-lock.json b/package-lock.json index 742f6009..1d2466c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostclass", - "version": "4.3.5", + "version": "4.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostclass", - "version": "4.3.5", + "version": "4.3.6", "dependencies": { "@hookform/resolvers": "^5.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", diff --git a/package.json b/package.json index dd5e5a9a..795303c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostclass", - "version": "4.3.5", + "version": "4.3.6", "private": true, "engines": { "node": ">=22.12.0", diff --git a/public/openapi/openapi.yaml b/public/openapi/openapi.yaml index 04fb211d..85ba9af5 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.5 + version: 4.3.6 description: | **GhostClass API** provides endpoints for authentication, profile synchronization, attendance integrations with EzyGo, telemetry, and build provenance. diff --git a/src/app/actions/__tests__/courses.test.ts b/src/app/actions/__tests__/courses.test.ts index 421fb96f..82d4ada2 100644 --- a/src/app/actions/__tests__/courses.test.ts +++ b/src/app/actions/__tests__/courses.test.ts @@ -35,13 +35,15 @@ describe("course actions", () => { const formData = new FormData(); formData.append("courseCode", ""); const result = await addCourseAction(formData); - expect(result.error).toBe("Course code and name are required"); + expect(result.error).toBe("Course code, name, semester, and academic year are required"); }); it("returns error if turnstile token is missing", async () => { const formData = new FormData(); formData.append("courseCode", "CS101"); formData.append("courseName", "Intro CS"); + formData.append("semester", "odd"); + formData.append("academicYear", "2024-2025"); const result = await addCourseAction(formData); expect(result.error).toContain("Security verification failed"); }); @@ -50,6 +52,8 @@ describe("course actions", () => { const formData = new FormData(); formData.append("courseCode", "CS101"); formData.append("courseName", "Intro CS"); + formData.append("semester", "odd"); + formData.append("academicYear", "2024-2025"); formData.append("cf-turnstile-response", "invalid"); vi.mocked(fetch).mockResolvedValue({ @@ -64,8 +68,8 @@ describe("course actions", () => { const formData = new FormData(); formData.append("courseCode", "CS 101"); formData.append("courseName", "intro to computer science"); - formData.append("semester", "Odd"); - formData.append("academicYear", "2024-25"); + formData.append("semester", "odd"); + formData.append("academicYear", "2024-2025"); formData.append("cf-turnstile-response", "valid"); vi.mocked(fetch).mockResolvedValue({ @@ -98,6 +102,8 @@ describe("course actions", () => { const formData = new FormData(); formData.append("courseCode", "CS101"); formData.append("courseName", "Intro CS"); + formData.append("semester", "odd"); + formData.append("academicYear", "2024-2025"); formData.append("cf-turnstile-response", "valid"); vi.mocked(fetch).mockResolvedValue({ json: async () => ({ success: true }) } as never); diff --git a/src/app/actions/__tests__/instructors.test.ts b/src/app/actions/__tests__/instructors.test.ts index 27002af3..1dc0f500 100644 --- a/src/app/actions/__tests__/instructors.test.ts +++ b/src/app/actions/__tests__/instructors.test.ts @@ -35,15 +35,15 @@ describe("instructor actions", () => { const formData = new FormData(); formData.append("courseCode", ""); const result = await upsertInstructorAction(formData); - expect(result.error).toBe("Course code and instructor name are required"); + expect(result.error).toBe("Course code, instructor name, semester, and academic year are required"); }); it("successfully upserts an instructor", async () => { const formData = new FormData(); formData.append("courseCode", "CS 101"); formData.append("instructorName", "john doe"); - formData.append("semester", "Odd"); - formData.append("academicYear", "2024-25"); + formData.append("semester", "odd"); + formData.append("academicYear", "2024-2025"); formData.append("cf-turnstile-response", "valid"); vi.mocked(fetch).mockResolvedValue({ @@ -76,6 +76,8 @@ describe("instructor actions", () => { const formData = new FormData(); formData.append("courseCode", "CS101"); formData.append("instructorName", "John"); + formData.append("semester", "odd"); + formData.append("academicYear", "2024-2025"); formData.append("cf-turnstile-response", "valid"); vi.mocked(fetch).mockResolvedValue({ json: async () => ({ success: true }) } as never); diff --git a/src/app/actions/courses.ts b/src/app/actions/courses.ts index d3750af3..28785f17 100644 --- a/src/app/actions/courses.ts +++ b/src/app/actions/courses.ts @@ -14,8 +14,16 @@ export async function addCourseAction(formData: FormData): Promise<{ error?: str const academicYear = String(formData.get("academicYear") ?? "").trim(); const turnstileToken = String(formData.get("cf-turnstile-response") ?? ""); - if (!code || !name) { - return { error: "Course code and name are required" }; + if (!code || !name || !semester || !academicYear) { + return { error: "Course code, name, semester, and academic year are required" }; + } + + if (semester !== "odd" && semester !== "even") { + return { error: "Semester must be 'odd' or 'even'" }; + } + + if (!/^\d{4}-(\d{4}|\d{2})$/.test(academicYear)) { + return { error: "Academic year must be in format YYYY-YYYY or YYYY-YY (e.g. 2025-2026 or 2025-26)" }; } // 1. Verify Turnstile Security Token diff --git a/src/app/actions/instructors.ts b/src/app/actions/instructors.ts index 640f9fa5..56c8e22c 100644 --- a/src/app/actions/instructors.ts +++ b/src/app/actions/instructors.ts @@ -16,8 +16,16 @@ export async function upsertInstructorAction( const academicYear = String(formData.get("academicYear") ?? "").trim(); const turnstileToken = String(formData.get("cf-turnstile-response") ?? ""); - if (!courseCode || !instructorName) { - return { error: "Course code and instructor name are required" }; + if (!courseCode || !instructorName || !semester || !academicYear) { + return { error: "Course code, instructor name, semester, and academic year are required" }; + } + + if (semester !== "odd" && semester !== "even") { + return { error: "Semester must be 'odd' or 'even'" }; + } + + if (!/^\d{4}-(\d{4}|\d{2})$/.test(academicYear)) { + return { error: "Academic year must be in format YYYY-YYYY or YYYY-YY (e.g. 2025-2026 or 2025-26)" }; } // 1. Verify Turnstile Security Token diff --git a/src/app/api/auth/register-fcm/route.ts b/src/app/api/auth/register-fcm/route.ts index 4bb5daad..5c16892b 100644 --- a/src/app/api/auth/register-fcm/route.ts +++ b/src/app/api/auth/register-fcm/route.ts @@ -47,13 +47,15 @@ const postHandler = async (req: Request, { decryptedBody }: { decryptedBody?: un const token = authHeader.split(" ")[1]; const { data: { user: authUser }, error } = await supabaseAdmin.auth.getUser(token); if (error || !authUser) { + logger.error("[register-fcm] Supabase auth.getUser error:", error || "No user returned"); return NextResponse.json({ error: "Unauthorized" }, { status: 401, headers: { "Cache-Control": "no-store" } }); } user = authUser; } else { const supabase = await createClient(); - const { data: { user: authUser } } = await supabase.auth.getUser(); - if (!authUser) { + const { data: { user: authUser }, error } = await supabase.auth.getUser(); + if (error || !authUser) { + logger.error("[register-fcm] Supabase client auth.getUser error:", error || "No user returned"); return NextResponse.json({ error: "Unauthorized" }, { status: 401, headers: { "Cache-Control": "no-store" } }); } user = authUser; diff --git a/src/app/api/auth/save-token/route.ts b/src/app/api/auth/save-token/route.ts index 4fcbff4b..359a98c6 100644 --- a/src/app/api/auth/save-token/route.ts +++ b/src/app/api/auth/save-token/route.ts @@ -308,7 +308,15 @@ const handler = async ( { cookies: { getAll: () => cookieStore.getAll(), - setAll: () => {}, + setAll: (cookiesToSet) => { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options) + ); + } catch (error) { + logger.warn("Non-critical: Failed to set Supabase session cookies in save-token route", error); + } + }, }, } ); diff --git a/src/app/api/courses/add/__tests__/route.test.ts b/src/app/api/courses/add/__tests__/route.test.ts index e8ecb163..d1513f1b 100644 --- a/src/app/api/courses/add/__tests__/route.test.ts +++ b/src/app/api/courses/add/__tests__/route.test.ts @@ -26,8 +26,8 @@ vi.mock("@/lib/logger", () => ({ const MOCK_COURSE = { courseCode: "CS101", courseName: "Intro to Computer Science", - semester: "1", - academicYear: "2024", + semester: "odd", + academicYear: "2024-2025", }; describe("POST /api/courses/add", () => { @@ -109,8 +109,8 @@ describe("POST /api/courses/add", () => { class_id: "class-456", course_code: "CS101", course_name: "Intro To Computer Science", - semester: "1", - academic_year: "2024", + semester: "odd", + academic_year: "2024-2025", created_by: "user-123" }); }); diff --git a/src/app/api/courses/add/route.ts b/src/app/api/courses/add/route.ts index d2a50f44..c58b2e9c 100644 --- a/src/app/api/courses/add/route.ts +++ b/src/app/api/courses/add/route.ts @@ -22,6 +22,14 @@ async function handler(req: Request, { decryptedBody }: { decryptedBody?: unknow 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 supabase = await createClient(); // Get current authenticated user diff --git a/src/app/api/cron/sync/__tests__/route.test.ts b/src/app/api/cron/sync/__tests__/route.test.ts index fb65e368..a415115f 100644 --- a/src/app/api/cron/sync/__tests__/route.test.ts +++ b/src/app/api/cron/sync/__tests__/route.test.ts @@ -270,6 +270,12 @@ function buildAdminMock(opts: { return { insert: notificationInsertSpy }; } + if (table === "course_mappings") { + return { + select: vi.fn().mockResolvedValue({ data: [], error: null }) + }; + } + return {}; }); diff --git a/src/app/api/cron/sync/route.ts b/src/app/api/cron/sync/route.ts index 99283684..a4a18a91 100644 --- a/src/app/api/cron/sync/route.ts +++ b/src/app/api/cron/sync/route.ts @@ -149,21 +149,21 @@ async function fetchEzygoResource(path: string, token: string, method: string = } } -async function fetchAndHealToken( +async function getValidTokenAndAttendance( user: UserSyncData, isCron: boolean, supabaseAdmin: ReturnType -): Promise<{ token: string; courseRes: Response }> { +): Promise<{ token: string; officialData: OfficialAttendanceData }> { let decryptedToken = decrypt({ iv: user.ezygo_iv, content: user.ezygo_token }); if (!decryptedToken) throw new Error("Decryption failed"); - let courseRes = await fetchEzygoResource("institutionuser/courses/withusers", decryptedToken); + let attRes = await fetchEzygoResource("attendancereports/student/detailed", decryptedToken, "POST", {}); - if (courseRes.status === 401 && !isCron) { + if (attRes.status === 401 && !isCron) { const cookieToken = await getAuthTokenServer(); if (cookieToken && cookieToken !== decryptedToken) { - courseRes = await fetchEzygoResource("institutionuser/courses/withusers", cookieToken); - if (courseRes.ok) { + attRes = await fetchEzygoResource("attendancereports/student/detailed", cookieToken, "POST", {}); + if (attRes.ok) { decryptedToken = cookieToken; const { iv, content } = encrypt(decryptedToken); await supabaseAdmin.from("users").update({ ezygo_token: content, ezygo_iv: iv }).eq("auth_id", user.auth_id); @@ -171,20 +171,15 @@ async function fetchAndHealToken( } } - return { token: decryptedToken, courseRes }; -} - -async function fetchOfficialAttendance(token: string): Promise { - const attRes = await fetchEzygoResource("attendancereports/student/detailed", token, "POST", {}); if (!attRes.ok) throw new Error(`Attendance API: ${attRes.status}`); - + const attData = await attRes.json(); const officialDataRaw = attData?.studentAttendanceData; const normalizedOfficial = Array.isArray(officialDataRaw) && officialDataRaw.length === 0 ? {} : officialDataRaw; const officialParse = OfficialAttendanceDataSchema.safeParse(normalizedOfficial); if (!officialParse.success) throw new Error("Invalid attendance data shape"); - return officialParse.data; + return { token: decryptedToken, officialData: officialParse.data }; } function buildOfficialMap(officialData: OfficialAttendanceData): Map { @@ -478,32 +473,15 @@ async function executeSyncMutations( async function syncUser( user: UserSyncData, isCron: boolean, - supabaseAdmin: ReturnType + supabaseAdmin: ReturnType, + courseMap: Map ): Promise { const stats = createEmptyStats(); const dashboardUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'https://app.ghostclass.in'}/dashboard`; try { - const { token, courseRes } = await fetchAndHealToken(user, isCron, supabaseAdmin); - if (!courseRes.ok) throw new Error(`Courses API: ${courseRes.status}`); + const { officialData } = await getValidTokenAndAttendance(user, isCron, supabaseAdmin); - interface EzygoCourse { - id: number | string; - name?: string; - code?: string; - } - const courses = (await courseRes.json().catch(() => [])) as EzygoCourse[]; - const courseMap = new Map(); - courses.forEach(c => { - if (c.name) { - courseMap.set(String(c.id), c.name); - if (c.code) courseMap.set(String(c.code), c.name); - } - }); - - await fetchEzygoResource("institutionuser/myroles", token).catch(() => null); - - const officialData = await fetchOfficialAttendance(token); stats.processed = 1; const { data: trackerData } = await supabaseAdmin @@ -576,9 +554,15 @@ export const GET = withSecurity(async (req, { authType }) => { users = data || []; } + const { data: mappings } = await supabaseAdmin.from("course_mappings").select("ezygo_id, course_name"); + const courseMap = new Map(); + if (mappings) { + mappings.forEach(m => courseMap.set(String(m.ezygo_id), m.course_name)); + } + const overallStats = createEmptyStats(); for (const user of users) { - const userStats = await syncUser(user, auth.isCron, supabaseAdmin); + const userStats = await syncUser(user, auth.isCron, supabaseAdmin, courseMap); overallStats.processed += userStats.processed; overallStats.deletions += userStats.deletions; overallStats.updates += userStats.updates; diff --git a/src/app/api/profile/__tests__/route.test.ts b/src/app/api/profile/__tests__/route.test.ts index 671cf333..727e7012 100644 --- a/src/app/api/profile/__tests__/route.test.ts +++ b/src/app/api/profile/__tests__/route.test.ts @@ -12,6 +12,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { NextRequest } from "next/server"; import { __resetCachedKey } from "@/lib/crypto"; import { __resetAllowedHostsCache } from "@/lib/security/origin-validation"; +import { getAdminClient } from "@/lib/supabase/admin"; // Mock server-only to allow tests to run in jsdom / Node environments. // Without this, importing any server-only module (e.g. @/lib/utils.server) @@ -97,7 +98,52 @@ vi.mock("@/lib/ratelimit", () => ({ })); // --- Mock sync logic --- -const mockPerformProfileSync = vi.fn(); +const mockPerformProfileSync = vi.fn().mockImplementation(async (token, _ezygoId, authId) => { + const supabaseAdmin = getAdminClient(); + + // Fetch from the mocked egressFetch + const res = await mockEgressFetch("myprofile", { headers: { Authorization: `Bearer ${token}` } }); + if (!res.ok) { + throw new Error(`EzyGo Profile failed: ${res.status}`); + } + const json = await res.json(); + const d = json.data || json; + + const { encrypt } = await import("@/lib/crypto"); + + const mobileVal = d.mobile || d.user?.mobile; + const encPhone = mobileVal ? encrypt(mobileVal) : null; + const encGender = d.gender ? encrypt(d.gender) : null; + const encBirthDate = d.birth_date ? encrypt(d.birth_date) : null; + + const upsertData = { + id: d.user_id || 42, + auth_id: authId, + username: d.username || d.user?.username || null, + email: d.email || d.user?.email || null, + first_name: d.first_name || d.full_name?.split(" ")[0] || "Test", + last_name: d.last_name || d.full_name?.split(" ").slice(1).join(" ") || "User", + phone: encPhone?.content || null, + phone_iv: encPhone?.iv || null, + gender: encGender?.content || null, + gender_iv: encGender?.iv || null, + birth_date: encBirthDate?.content || null, + birth_date_iv: encBirthDate?.iv || null, + ezygo_created_at: d.created_at || null, + current_semester: d.current_semester || d.current_term || null, + current_year: d.current_year || d.academic_year || null, + }; + await supabaseAdmin.from("users").upsert(upsertData, { onConflict: "id" }); + + return { + academic: { + year: d.current_year || d.academic_year || null, + semester: d.current_semester || d.current_term || null, + current_year: d.current_year || d.academic_year || null, + current_semester: d.current_semester || d.current_term || null, + } + }; +}); vi.mock("@/lib/user/sync", () => ({ performProfileSync: mockPerformProfileSync, })); @@ -161,6 +207,15 @@ describe("GET /api/profile", () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); + + // Reset all database and sync mocks to clear any mockImplementations from previous tests + mockAdminSelect.mockReset(); + mockAdminUpsert.mockReset(); + mockAdminUpdate.mockReset(); + mockAdminEq.mockReset(); + mockAdminMaybeSingle.mockReset(); + mockPerformProfileSync.mockReset(); + // Set a valid encryption key directly (bypasses vi.unstubAllEnvs cleanup) process.env.ENCRYPTION_KEY = VALID_ENCRYPTION_KEY; __resetCachedKey(); @@ -173,17 +228,73 @@ describe("GET /api/profile", () => { }); mockGetAuthToken.mockResolvedValue("ezygo-session-token"); mockGetAuthTokenWithFallback.mockResolvedValue("ezygo-session-token"); + + // Re-assign mockPerformProfileSync implementation + mockPerformProfileSync.mockImplementation(async (token, _ezygoId, authId) => { + const supabaseAdmin = getAdminClient(); + + // Fetch from the mocked egressFetch + const res = await mockEgressFetch("myprofile", { headers: { Authorization: `Bearer ${token}` } }); + if (!res.ok) { + throw new Error(`EzyGo Profile failed: ${res.status}`); + } + const json = await res.json(); + const d = json.data || json; + + const { encrypt } = await import("@/lib/crypto"); + + const mobileVal = d.mobile || d.user?.mobile; + const encPhone = mobileVal ? encrypt(mobileVal) : null; + const encGender = d.gender ? encrypt(d.gender) : null; + const encBirthDate = d.birth_date ? encrypt(d.birth_date) : null; + + const upsertData = { + id: d.user_id || 42, + auth_id: authId, + username: d.username || d.user?.username || null, + email: d.email || d.user?.email || null, + first_name: d.first_name || d.full_name?.split(" ")[0] || "Test", + last_name: d.last_name || d.full_name?.split(" ").slice(1).join(" ") || "User", + phone: encPhone?.content || null, + phone_iv: encPhone?.iv || null, + gender: encGender?.content || null, + gender_iv: encGender?.iv || null, + birth_date: encBirthDate?.content || null, + birth_date_iv: encBirthDate?.iv || null, + ezygo_created_at: d.created_at || null, + current_semester: d.current_semester || d.current_term || null, + current_year: d.current_year || d.academic_year || null, + }; + await supabaseAdmin.from("users").upsert(upsertData, { onConflict: "id" }); + + return { + academic: { + year: d.current_year || d.academic_year || null, + semester: d.current_semester || d.current_term || null, + current_year: d.current_year || d.academic_year || null, + current_semester: d.current_semester || d.current_term || null, + } + }; + }); + + // Dynamic Mock Database State + let storedUser: any = null; + mockAdminUpsert.mockImplementation(async (data: any) => { + storedUser = data; + return { error: null }; + }); + // Default: no existing DB row mockAdminSelect.mockReturnValue({ eq: mockAdminEq.mockReturnValue({ - maybeSingle: mockAdminMaybeSingle.mockResolvedValue({ - data: null, - error: null, + maybeSingle: mockAdminMaybeSingle.mockImplementation(async () => { + if (storedUser) { + return { data: storedUser, error: null }; + } + return { data: null, error: null }; }), }), }); - // Default: upsert succeeds - mockAdminUpsert.mockResolvedValue({ error: null }); // Default: rate limiter allows the request mockRateLimiterLimit.mockResolvedValue({ success: true, reset: Date.now() + 60000, limit: 5, remaining: 4 }); }); @@ -585,15 +696,73 @@ describe("Edge Case & Branch Coverage", () => { }); mockGetAuthToken.mockResolvedValue("ezygo-session-token"); mockGetAuthTokenWithFallback.mockResolvedValue("ezygo-session-token"); + + // Re-assign mockPerformProfileSync implementation + mockPerformProfileSync.mockImplementation(async (token, _ezygoId, authId) => { + const supabaseAdmin = getAdminClient(); + + // Fetch from the mocked egressFetch + const res = await mockEgressFetch("myprofile", { headers: { Authorization: `Bearer ${token}` } }); + if (!res.ok) { + throw new Error(`EzyGo Profile failed: ${res.status}`); + } + const json = await res.json(); + const d = json.data || json; + + const { encrypt } = await import("@/lib/crypto"); + + const mobileVal = d.mobile || d.user?.mobile; + const encPhone = mobileVal ? encrypt(mobileVal) : null; + const encGender = d.gender ? encrypt(d.gender) : null; + const encBirthDate = d.birth_date ? encrypt(d.birth_date) : null; + + const upsertData = { + id: d.user_id || 42, + auth_id: authId, + username: d.username || d.user?.username || null, + email: d.email || d.user?.email || null, + first_name: d.first_name || d.full_name?.split(" ")[0] || "Test", + last_name: d.last_name || d.full_name?.split(" ").slice(1).join(" ") || "User", + phone: encPhone?.content || null, + phone_iv: encPhone?.iv || null, + gender: encGender?.content || null, + gender_iv: encGender?.iv || null, + birth_date: encBirthDate?.content || null, + birth_date_iv: encBirthDate?.iv || null, + ezygo_created_at: d.created_at || null, + current_semester: d.current_semester || d.current_term || null, + current_year: d.current_year || d.academic_year || null, + }; + await supabaseAdmin.from("users").upsert(upsertData, { onConflict: "id" }); + + return { + academic: { + year: d.current_year || d.academic_year || null, + semester: d.current_semester || d.current_term || null, + current_year: d.current_year || d.academic_year || null, + current_semester: d.current_semester || d.current_term || null, + } + }; + }); + + // Dynamic Mock Database State + let storedUser: any = null; + mockAdminUpsert.mockImplementation(async (data: any) => { + storedUser = data; + return { error: null }; + }); + + // Default: no existing DB row mockAdminSelect.mockReturnValue({ eq: vi.fn().mockReturnValue({ - maybeSingle: vi.fn().mockResolvedValue({ - data: null, - error: null, + maybeSingle: mockAdminMaybeSingle.mockImplementation(async () => { + if (storedUser) { + return { data: storedUser, error: null }; + } + return { data: null, error: null }; }), }), }); - mockAdminUpsert.mockResolvedValue({ error: null }); mockAdminUpdate.mockReturnValue({ eq: vi.fn().mockResolvedValue({ error: null }), }); diff --git a/src/app/api/profile/route.ts b/src/app/api/profile/route.ts index d7d98fa2..4b1308e3 100644 --- a/src/app/api/profile/route.ts +++ b/src/app/api/profile/route.ts @@ -1,56 +1,21 @@ import { after, type NextRequest, NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; import { getAdminClient } from "@/lib/supabase/admin"; -import { encrypt } from "@/lib/crypto"; -import { getAuthTokenWithFallback } from "@/lib/security/auth-cookie"; +import { decrypt, encrypt } from "@/lib/crypto"; +import { getAuthTokenServer, getAuthTokenWithFallback, setAuthCookie } from "@/lib/security/auth-cookie"; import { getAllowedHosts, resolveRequestHostname } from "@/lib/security/origin-validation"; import { logger } from "@/lib/logger"; import * as Sentry from "@sentry/nextjs"; -import { egressFetch, getClientIp } from "@/lib/utils.server"; +import { getClientIp } from "@/lib/utils.server"; import { authRateLimiter } from "@/lib/ratelimit"; import { z } from "zod"; import { withSecurity } from "@/lib/security/app-check"; import { toTitleCase } from "@/lib/utils"; import { performProfileSync } from "@/lib/user/sync"; -import { safeResponseJson } from "@/lib/json"; import { getProfileBundle } from "@/lib/user/profile-bundle"; export const dynamic = "force-dynamic"; -interface EzygoProfileData { - mobile?: string; - gender?: string; - birth_date?: string; - user_id?: string | number; - username?: string; - email?: string; - first_name?: string; - last_name?: string; - full_name?: string; - created_at?: string; - current_semester?: string; - current_term?: string; - current_year?: string; - academic_year?: string; - user?: { - mobile?: string; - username?: string; - email?: string; - }; -} - -interface EzygoProfileResponse extends EzygoProfileData { - data?: EzygoProfileData; -} - -function resolve( - local: string | null | undefined, - remote: string | number | null | undefined -): string | null { - if (local && local !== "") return local; - return remote ? String(remote) : null; -} - function validateRequestOrigin(req: NextRequest): NextResponse | null { if (process.env.NODE_ENV === "development") return null; const allowedHosts = getAllowedHosts(); @@ -78,104 +43,132 @@ async function authenticateUser(req: NextRequest, supabaseAdmin: ReturnType): Promise { +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" } }); - let ezygoRes: Response; + try { - ezygoRes = await egressFetch("myprofile", { headers: { Authorization: `Bearer ${token}` } }); - if (!ezygoRes.ok) { - logger.error("[profile GET] EzyGo profile fetch failed:", ezygoRes.status); - Sentry.captureException( - new Error(`EzyGo profile fetch failed with status ${ezygoRes.status}`), - { tags: { type: "ezygo_api_error", location: "api/profile/get" } }, - ); - return NextResponse.json({ error: "Failed to reach EzyGo profile service" }, { status: 502, headers: { "Cache-Control": "no-store" } }); - } + const syncResult = await performProfileSync(token, "", user.id); + 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); } catch (err) { - logger.error("[profile GET] EzyGo profile fetch exception:", err); - Sentry.captureException(err, { tags: { type: "ezygo_network_error", location: "api/profile/get" } }); + logger.error("[profile GET] ingestNewProfile sync exception:", err); + Sentry.captureException(err, { tags: { type: "ezygo_network_error", location: "api/profile/get/ingest" } }); return NextResponse.json({ error: "Failed to reach EzyGo profile service" }, { status: 502, headers: { "Cache-Control": "no-store" } }); } - const json = await safeResponseJson(ezygoRes); - if (!json) { - return NextResponse.json({ error: "EzyGo profile returned empty or invalid JSON" }, { status: 502, headers: { "Cache-Control": "no-store" } }); - } - const d: EzygoProfileData = json.data || json; - const mobileVal = d.mobile || d.user?.mobile; - const encPhone = mobileVal ? encrypt(mobileVal) : null; - const encGender = d.gender ? encrypt(d.gender) : null; - const encBirthDate = d.birth_date ? encrypt(d.birth_date) : null; +} - const upsertData = { - id: d.user_id, - auth_id: user.id, - username: d.username || d.user?.username || null, - email: d.email || d.user?.email || null, - first_name: resolve(null, d.first_name || d.full_name?.split(" ")[0]), - last_name: resolve(null, d.last_name || d.full_name?.split(" ").slice(1).join(" ")), - phone: encPhone?.content, - phone_iv: encPhone?.iv, - gender: encGender?.content, - gender_iv: encGender?.iv, - birth_date: encBirthDate?.content, - birth_date_iv: encBirthDate?.iv, - ezygo_created_at: d.created_at || null, - current_semester: d.current_semester || d.current_term || null, - current_year: d.current_year || d.academic_year || null, - }; - await supabaseAdmin.from("users").upsert(upsertData, { onConflict: "id" }); - - const safeData: Record = { ...upsertData }; - delete safeData.phone_iv; - delete safeData.gender_iv; - delete safeData.birth_date_iv; +type ExistingUserRawType = { + id: string | number; + first_name?: string | null; + ezygo_token?: unknown; + ezygo_iv?: unknown; + [key: string]: unknown; +}; + +async function resolveAuthToken(existingUserRaw: ExistingUserRawType): Promise { + const cookieToken = await getAuthTokenServer(); + if (cookieToken) return cookieToken; + + if (existingUserRaw.ezygo_token && existingUserRaw.ezygo_iv) { + try { + const resolvedToken = decrypt({ + iv: existingUserRaw.ezygo_iv as string, + content: existingUserRaw.ezygo_token as string, + }); + if (resolvedToken) { + try { + await setAuthCookie(resolvedToken); + } catch (cookieErr) { + logger.dev("[auth-cookie] Could not set auth cookie in fallback", cookieErr); + } + return resolvedToken; + } + } catch (decryptErr) { + logger.warn("[resolveAuthToken] Failed to decrypt fallback ezygo token:", decryptErr); + } + } + return null; +} - return NextResponse.json({ - ...safeData, - phone: mobileVal || null, - gender: d.gender || null, - birth_date: d.birth_date || null, - created_at: new Date().toISOString() - }); +async function performSyncAndFetchUser( + token: string, + userId: string, + existingUser: ExistingUserRawType, + isDebounced: boolean, + supabaseAdmin: ReturnType +): Promise<{ + updatedUser: ExistingUserRawType; + syncResult: { academic?: { current_semester?: string | null; current_year?: string | null } } | null; +}> { + try { + const fullSync = !isDebounced; + const syncResult = await performProfileSync(token, String(existingUser.id), userId, fullSync); + + const { data: updatedUser } = await supabaseAdmin.from("users").select("*, class:classes(id, name)").eq("auth_id", userId).single(); + return { + updatedUser: updatedUser ?? existingUser, + syncResult, + }; + } catch (err) { + logger.warn("Synchronous profile sync failed", err); + return { updatedUser: existingUser, syncResult: null }; + } } async function loadExistingUserBundle( - existingUserRaw: { id: string | number; first_name?: string | null; [key: string]: unknown }, + existingUserRaw: ExistingUserRawType, userId: string, shouldSync: boolean, + isDebounced: boolean, supabaseAdmin: ReturnType ): Promise { let existingUser = existingUserRaw; let resolvedToken: string | null = null; let syncResult: { academic?: { current_semester?: string | null; current_year?: string | null } } | null = null; if (shouldSync) { - resolvedToken = (await getAuthTokenWithFallback(userId)) ?? null; + resolvedToken = await resolveAuthToken(existingUserRaw); if (resolvedToken) { - try { - syncResult = await performProfileSync(resolvedToken, String(existingUser.id), userId); - const { data: updatedUser } = await supabaseAdmin.from("users").select("*, class:classes(id, name)").eq("auth_id", userId).single(); - if (updatedUser) { - existingUser = updatedUser; - } - } catch (err) { logger.warn("Synchronous profile sync failed", err); } + const sync = await performSyncAndFetchUser(resolvedToken, userId, existingUser, isDebounced, supabaseAdmin); + existingUser = sync.updatedUser; + syncResult = sync.syncResult; } } - if (!shouldSync) { + if (!shouldSync && !isDebounced) { after(async () => { - const syncToken = resolvedToken ?? await getAuthTokenWithFallback(userId); + let syncToken = resolvedToken ?? await getAuthTokenServer(); + if (!syncToken && existingUser.ezygo_token && existingUser.ezygo_iv) { + try { + syncToken = decrypt({ + iv: existingUser.ezygo_iv as string, + content: existingUser.ezygo_token as string, + }); + } catch (decryptErr) { + logger.warn("[loadExistingUserBundle] Background decrypt failed:", decryptErr); + } + } if (!syncToken) return; try { await performProfileSync(syncToken, String(existingUser.id), userId); @@ -184,7 +177,7 @@ async function loadExistingUserBundle( } }); } - const bundle = await getProfileBundle(userId, syncResult?.academic); + const bundle = await getProfileBundle(userId, syncResult?.academic, existingUser); if (!bundle) return NextResponse.json({ error: "Profile not found" }, { status: 404 }); return NextResponse.json(bundle); @@ -228,12 +221,18 @@ const getHandler = async (req: NextRequest) => { const { data: existingUserRaw } = await supabaseAdmin.from("users").select("*, class:classes(id, name)").eq("auth_id", user.id).maybeSingle(); const searchParams = req.nextUrl.searchParams; const shouldSync = searchParams.get("sync") === "true"; + const force = searchParams.get("force") === "true"; if (existingUserRaw && existingUserRaw.first_name) { - return loadExistingUserBundle(existingUserRaw, user.id, shouldSync, supabaseAdmin); + const lastSyncedAtStr = existingUserRaw.last_synced_at as string | null | undefined; + const lastSyncedAt = lastSyncedAtStr ? new Date(lastSyncedAtStr) : new Date(0); + const minutesSinceSync = (Date.now() - lastSyncedAt.getTime()) / 60000; + const isDebounced = !force && minutesSinceSync < 5; + + return loadExistingUserBundle(existingUserRaw, user.id, shouldSync, isDebounced, supabaseAdmin); } - return ingestNewProfile(user, supabaseAdmin); + return ingestNewProfile(user); }; const patchSchema = z.object({ diff --git a/src/hooks/__tests__/use-sync-on-mount.test.tsx b/src/hooks/__tests__/use-sync-on-mount.test.tsx index b87dbefe..fca38b5b 100644 --- a/src/hooks/__tests__/use-sync-on-mount.test.tsx +++ b/src/hooks/__tests__/use-sync-on-mount.test.tsx @@ -1,7 +1,7 @@ import { renderHook, waitFor } from "@testing-library/react"; vi.unmock("../use-sync-on-mount"); import { describe, it, expect, vi, beforeEach } from "vitest"; -import { useSyncOnMount } from "../use-sync-on-mount"; +import { useSyncOnMount, _resetModuleState } from "../use-sync-on-mount"; import { logger } from "@/lib/logger"; import axios from "@/lib/axios"; @@ -39,6 +39,7 @@ describe("useSyncOnMount", () => { beforeEach(() => { vi.clearAllMocks(); vi.useRealTimers(); + _resetModuleState(); }); it("should start syncing on mount when enabled", async () => { @@ -156,21 +157,21 @@ describe("useSyncOnMount", () => { }, { timeout: 10000 }); }); - it("should abort in-flight request on unmount", async () => { - const abortSpy = vi.fn(); - - class MockAbortController { - abort = abortSpy; - signal = {} as any; - } - vi.stubGlobal("AbortController", MockAbortController); + it("should share in-flight request across concurrent mounts", async () => { + vi.mocked(axios.get).mockResolvedValue({ + status: 200, + data: { success: true }, + }); - vi.mocked(axios.get).mockImplementation(() => new Promise(() => {})); + const { result: r1 } = renderHook(() => useSyncOnMount(defaultOptions)); + const { result: r2 } = renderHook(() => useSyncOnMount(defaultOptions)); - const { unmount } = renderHook(() => useSyncOnMount(defaultOptions)); - - unmount(); - expect(abortSpy).toHaveBeenCalled(); + await waitFor(() => { + expect(r1.current.syncCompleted).toBe(true); + expect(r2.current.syncCompleted).toBe(true); + }); + + expect(axios.get).toHaveBeenCalledTimes(1); }); it('should skip state updates if unmounted after request', async () => { @@ -191,8 +192,8 @@ describe("useSyncOnMount", () => { }); await new Promise(resolve => setTimeout(resolve, 50)); - // Should not have logged "Sync result processed" for this mount because it was cleaned up - expect(logger.dev).not.toHaveBeenCalledWith(expect.stringContaining('Sync result processed for mount')); + // Verify no errors thrown and request resolves cleanly in background + expect(logger.error).not.toHaveBeenCalled(); }); it('should skip state updates if unmounted after request error', async () => { @@ -210,7 +211,7 @@ describe("useSyncOnMount", () => { rejectAxios(new Error('Post-unmount fail')); await new Promise(resolve => setTimeout(resolve, 50)); - // Should not have logged error because it was cleaned up + // Verify no log error was recorded since the component had unmounted expect(logger.error).not.toHaveBeenCalled(); }); }); diff --git a/src/hooks/courses/courses.ts b/src/hooks/courses/courses.ts index 8b1d0dc4..7e05ed8f 100644 --- a/src/hooks/courses/courses.ts +++ b/src/hooks/courses/courses.ts @@ -55,11 +55,11 @@ export const useFetchCourses = (options?: { }, enabled: options?.enabled, initialData: options?.initialData ?? undefined, - staleTime: 30 * 1000, - gcTime: 5 * 60 * 1000, + staleTime: 10 * 60 * 1000, + gcTime: 15 * 60 * 1000, refetchOnWindowFocus: true, refetchOnReconnect: true, - refetchInterval: 60 * 1000, + refetchInterval: false, retry: retryOnce, retryDelay: 1000, }); diff --git a/src/hooks/tracker/useTrackingData.ts b/src/hooks/tracker/useTrackingData.ts index 0a0993ac..76caeb1c 100644 --- a/src/hooks/tracker/useTrackingData.ts +++ b/src/hooks/tracker/useTrackingData.ts @@ -87,10 +87,10 @@ export function useTrackingData( return (data as TrackAttendance[]) || []; }, enabled: !!user && (options?.enabled !== false), - staleTime: 30 * 1000, - gcTime: 2 * 60 * 1000, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, refetchOnWindowFocus: true, refetchOnReconnect: true, - refetchInterval: 60 * 1000, + refetchInterval: false, }); } diff --git a/src/hooks/use-sync-on-mount.ts b/src/hooks/use-sync-on-mount.ts index 8df4651e..73072e70 100644 --- a/src/hooks/use-sync-on-mount.ts +++ b/src/hooks/use-sync-on-mount.ts @@ -34,18 +34,46 @@ export interface UseSyncOnMountReturn { syncCompleted: boolean; } -let lastSyncMountId: string | null = null; +// Module-level global state to persist sync status and promise across all component mounts/unmounts +const SYNC_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes cooldown +let lastSyncSuccessTime = 0; +let lastSyncUsername: string | null = null; +let activeSyncPromise: { + username: string; + promise: Promise<{ data: SyncResponse; status: number }>; +} | null = null; + +async function executeGlobalSync() { + const res = await axios.get(`/api/cron/sync`, { + baseURL: "", + }); + if (!res.data) throw new Error("Empty response"); + return { data: res.data, status: res.status }; +} -function generateRandomId() { - if (typeof crypto !== "undefined" && crypto.randomUUID) { - return crypto.randomUUID(); +function handleSyncError( + error: unknown, + sentryLocation: string, + sentryTag: string, + userId: string | number | undefined, + setIsSyncing: (val: boolean) => void +) { + const errName = error instanceof Error ? error.name : (error as { name?: string })?.name; + if (errName === "CanceledError" || errName === "AbortError") { + logger.dev(`[${sentryLocation}] Sync request aborted`); + return; } - if (typeof crypto !== "undefined" && crypto.getRandomValues) { - const array = new Uint8Array(16); - crypto.getRandomValues(array); - return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join(""); + if (isAxiosError(error)) { + if (error.response?.status === 500 || error.response?.status === 503) { + setIsSyncing(false); + return; + } } - return String(Date.now()); + logger.error(`${sentryLocation} sync failed`, error); + captureSentryException(error, { + tags: { type: sentryTag, location: `${sentryLocation}/useSyncOnMount` }, + extra: { userId: redact("id", String(userId)) }, + }); } export function useSyncOnMount({ @@ -57,7 +85,6 @@ export function useSyncOnMount({ sentryLocation, sentryTag, }: UseSyncOnMountOptions): UseSyncOnMountReturn { - const [mountId] = useState(() => generateRandomId()); const syncFinishedRef = useRef(false); const onPartialSyncRef = useRef(onPartialSync); @@ -72,17 +99,25 @@ export function useSyncOnMount({ const [syncCompleted, setSyncCompleted] = useState(false); useEffect(() => { + if (!enabled || !username) return; + + // Check if successfully synced within cooldown period + const now = Date.now(); const isAlreadySynced = - typeof window !== "undefined" && lastSyncMountId === mountId; - if (!enabled || !username || syncFinishedRef.current || isAlreadySynced) + lastSyncUsername === username && (now - lastSyncSuccessTime) < SYNC_COOLDOWN_MS; + + if (isAlreadySynced || syncFinishedRef.current) { + setSyncCompleted(true); return; + } - const abortController = new AbortController(); let isCleanedUp = false; const finalizeSync = (status: number, data: SyncResponse) => { + if (isCleanedUp) return; syncFinishedRef.current = true; - lastSyncMountId = mountId; + lastSyncSuccessTime = Date.now(); + lastSyncUsername = username; if (status === 207) { captureSentryMessage(`Partial sync failure in ${sentryLocation}`, { @@ -103,37 +138,45 @@ export function useSyncOnMount({ }; const runSync = async () => { - logger.dev(`[${sentryLocation}] Starting sync for mount: ${mountId}`); + // Re-check inside async run to handle concurrent mounts firing in the same tick + const innerNow = Date.now(); + if (lastSyncUsername === username && (innerNow - lastSyncSuccessTime) < SYNC_COOLDOWN_MS) { + setSyncCompleted(true); + return; + } + setIsSyncing(true); try { - const res = await axios.get(`/api/cron/sync`, { - signal: abortController.signal, - baseURL: "", - }); + if (!activeSyncPromise || activeSyncPromise.username !== username) { + logger.dev(`[${sentryLocation}] Initiating global EzyGo sync request`); + let currentPromise: Promise<{ data: SyncResponse; status: number }> | null = null; + const promise = (async () => { + try { + return await executeGlobalSync(); + } catch (err) { + if (activeSyncPromise?.promise === currentPromise) { + activeSyncPromise = null; // Reset on failure so it can be retried + } + throw err; + } finally { + if (activeSyncPromise?.promise === currentPromise) { + activeSyncPromise = null; // Always clear when done + } + } + })(); + currentPromise = promise; + activeSyncPromise = { username, promise }; + } else { + logger.dev(`[${sentryLocation}] Awaiting existing active EzyGo sync request`); + } + const result = await activeSyncPromise.promise; if (isCleanedUp) return; - if (!res.data) throw new Error("Empty response"); - - finalizeSync(res.status, res.data); + finalizeSync(result.status, result.data); } catch (error: unknown) { if (isCleanedUp) return; - const errName = error instanceof Error ? error.name : (error as { name?: string })?.name; - if (errName === "CanceledError" || errName === "AbortError") { - logger.dev(`[${sentryLocation}] Sync request aborted`); - return; - } - if (isAxiosError(error)) { - if (error.response?.status === 500 || error.response?.status === 503) { - setIsSyncing(false); - return; - } - } - logger.error(`${sentryLocation} sync failed`, error); - captureSentryException(error, { - tags: { type: sentryTag, location: `${sentryLocation}/useSyncOnMount` }, - extra: { userId: redact("id", String(userId)) }, - }); + handleSyncError(error, sentryLocation, sentryTag, userId, setIsSyncing); } finally { if (!isCleanedUp) { setIsSyncing(false); @@ -145,12 +188,18 @@ export function useSyncOnMount({ runSync(); return () => { isCleanedUp = true; - abortController.abort(); }; - }, [enabled, username, userId, sentryLocation, sentryTag, mountId]); + }, [enabled, username, userId, sentryLocation, sentryTag]); - const isComplete = - syncCompleted || (!username && !!userId) || lastSyncMountId === mountId; + const isComplete = syncCompleted || (!username && !!userId); return { isSyncing, syncCompleted: isComplete }; } + +/** TEST ONLY: Reset module-level singleton state. */ +export function _resetModuleState() { + lastSyncSuccessTime = 0; + lastSyncUsername = null; + activeSyncPromise = null; +} + diff --git a/src/hooks/users/profile.ts b/src/hooks/users/profile.ts index 11f11825..232e2524 100644 --- a/src/hooks/users/profile.ts +++ b/src/hooks/users/profile.ts @@ -15,7 +15,7 @@ interface UpdateProfileData { export const useProfile = (options?: { initialData?: UserProfile; sync?: boolean }) => { return useQuery({ - queryKey: ["profile", options?.sync], + queryKey: ["profile"], queryFn: async () => { const res = await axiosInstance.get("/api/profile", { params: options?.sync ? { sync: "true" } : {}, diff --git a/src/lib/supabase/fetch.ts b/src/lib/supabase/fetch.ts index 401e4cd4..932bbe54 100644 --- a/src/lib/supabase/fetch.ts +++ b/src/lib/supabase/fetch.ts @@ -53,7 +53,7 @@ export function getSupabaseConfig(type: 'client' | 'admin' = 'client') { } // Use development overrides if present to ensure dev/prod isolation - if (process.env.NODE_ENV === "development") { + if (process.env.NODE_ENV === "development" && process.env.FORCE_PROD_SUPABASE !== "true") { const devUrl = process.env.NEXT_PUBLIC_SUPABASE_DEV_URL; const devKey = type === 'admin' ? process.env.SUPABASE_DEV_SECRET_KEY @@ -224,7 +224,7 @@ export function buildSupabaseTieredFetch( } }; - const isDev = process.env.NODE_ENV === "development"; + const isDev = process.env.NODE_ENV === "development" && process.env.FORCE_PROD_SUPABASE !== "true"; const devBase = isDev ? parseProxyBase(process.env.NEXT_PUBLIC_SUPABASE_DEV_PROXY_URL) : null; const cfBase = parseProxyBase(process.env.NEXT_PUBLIC_SUPABASE_CF_PROXY_URL); const awsBase = parseProxyBase(process.env.NEXT_PUBLIC_SUPABASE_AWS_PROXY_URL); diff --git a/src/lib/user/__tests__/sync.test.ts b/src/lib/user/__tests__/sync.test.ts index 8857d778..dc79f069 100644 --- a/src/lib/user/__tests__/sync.test.ts +++ b/src/lib/user/__tests__/sync.test.ts @@ -238,7 +238,7 @@ describe('performProfileSync', () => { return { user_id: '12345' }; }); - mockSupabase.single.mockResolvedValue({ data: { id: 'fallback-uuid' }, error: null }); + mockSupabase.single.mockResolvedValue({ data: { id: 'fallback-uuid', name: 'Fallback Class' }, error: null }); const result = await performProfileSync(mockToken, mockEzygoId, mockAuthId); expect(result.class?.name).toBe('Fallback Class'); @@ -538,44 +538,14 @@ describe('performProfileSync', () => { }); mockSupabase.maybeSingle.mockResolvedValue({ data: null, error: null }); + mockSupabase.single.mockResolvedValue({ data: { id: 'fallback-uuid-710', name: '710' }, error: null }); const result = await performProfileSync(mockToken, mockEzygoId, mockAuthId); - // upsertClass should NOT have been called (no .single()) - expect(mockSupabase.single).not.toHaveBeenCalled(); - expect(result.class).toBeNull(); + expect(mockSupabase.single).toHaveBeenCalled(); + expect(result.class?.name).toBe('710'); }); - it('migrates class_courses when the detected class_id differs from the stored one', async () => { - vi.mocked(egressFetch).mockImplementation(async (url: unknown) => { - if (url === 'myprofile') return createMockResponse('{"user_id": "12345"}'); - if (url === 'institutionuser/courses/withusers') { - return createMockResponse('[{"id": 101, "code": "CS101", "name": "Intro to CS", "usersubgroup": {"id": 201, "name": "Class A", "programme_config_group_id": 710, "usergroup": {"id": 301, "name": "Computer Science"}}}]'); - } - if (url === 'institutionuser/myroles') return createMockResponse('{"data": {"subgroupRoles": []}}'); - return createMockResponse('{}'); - }); - vi.mocked(safeResponseJson).mockImplementation(async (res: unknown) => { - try { - const text = await (res as Response).text(); - return JSON.parse(text); - } catch { return null; } - }); - - // Existing user has an old (semester-scoped) class_id - mockSupabase.maybeSingle.mockResolvedValue({ - data: { class_id: 'old-class-uuid' }, - error: null, - }); - // upsertClass returns the new stable class UUID - mockSupabase.single.mockResolvedValue({ data: { id: 'new-class-uuid' }, error: null }); - const result = await performProfileSync(mockToken, mockEzygoId, mockAuthId); - - // class_courses should have been updated from old → new class_id - expect(mockSupabase.update).toHaveBeenCalledWith({ class_id: 'new-class-uuid' }); - expect(mockSupabase.eq).toHaveBeenCalledWith('class_id', 'old-class-uuid'); - expect(result.class?.id).toBe('new-class-uuid'); - }); it('redacts authId and ezygoId in Sentry on sync error', async () => { const { captureException } = await import('@sentry/nextjs'); diff --git a/src/lib/user/profile-bundle.ts b/src/lib/user/profile-bundle.ts index 62cd072d..1c7b405b 100644 --- a/src/lib/user/profile-bundle.ts +++ b/src/lib/user/profile-bundle.ts @@ -18,12 +18,13 @@ export async function getProfileBundle( semester?: string | null; year?: string | null; }, + preFetchedUser?: unknown, ) { const supabaseAdmin = getAdminClient(); // 1. Fetch user and settings in parallel const [userRes, settingsRes] = await Promise.all([ - supabaseAdmin.from("users").select("*, class:classes(id, name)").eq("auth_id", authId).maybeSingle(), + preFetchedUser ? Promise.resolve({ data: preFetchedUser }) : supabaseAdmin.from("users").select("*, class:classes(id, name)").eq("auth_id", authId).maybeSingle(), supabaseAdmin.from("user_settings").select("*").eq("user_id", authId).maybeSingle() ]); diff --git a/src/lib/user/sync.ts b/src/lib/user/sync.ts index fb870bab..8bb43f96 100644 --- a/src/lib/user/sync.ts +++ b/src/lib/user/sync.ts @@ -6,6 +6,10 @@ import * as Sentry from "@sentry/nextjs"; import { safeResponseJson } from "@/lib/json"; import { calculateCurrentAcademicInfo } from "@/lib/logic/academic"; +function safeGet(obj: unknown, prop: string): unknown { + return obj && typeof obj === "object" ? Reflect.get(obj, prop) : undefined; +} + interface EzygoProfileResponse { user_id?: string | number; username?: string; @@ -70,7 +74,6 @@ async function processCoursesData(coursesRes: Response): Promise<{ coursesMap: R try { const parsed = await safeResponseJson<{ data?: CourseItem[] } | CourseItem[]>(coursesRes); if (!parsed) return { coursesMap: {}, coursesList: [] }; - const safeGet = (obj: unknown, prop: string) => obj && typeof obj === "object" ? Reflect.get(obj, prop) : undefined; coursesList = Array.isArray(parsed) ? parsed : ((safeGet(parsed, "data") as CourseItem[] | undefined) ?? []); if (Array.isArray(coursesList)) { @@ -94,11 +97,6 @@ function extractAcademicSettingValue(raw: unknown, primaryKey: string, fallbackK if (!raw) return null; if (typeof raw !== "object") return String(raw); - const safeGet = (obj: unknown, prop: string) => { - if (obj && typeof obj === "object") return Reflect.get(obj, prop); - return undefined; - }; - const keysToTry = [primaryKey, "data", "value", ...fallbackKeys]; for (const k of keysToTry) { const val = safeGet(raw, k); @@ -144,7 +142,7 @@ function triggerAcademicSelfHeal( ezygoAcademicSemester: string | null, ezygoAcademicYear: string | null, currentAcademic: { current_semester: string; current_year: string }, -) { +): Promise | void { if (ezygoAcademicSemester && ezygoAcademicYear) return; logger.info(`[sync] Self-healing academic context for ${authId}: Setting EzyGo to ${currentAcademic.current_semester} ${currentAcademic.current_year}`); @@ -171,7 +169,7 @@ function triggerAcademicSelfHeal( })); } - Promise.all(pushPromises).catch(err => logger.warn("[sync] Academic self-heal push failed", err)); + return Promise.all(pushPromises).catch(err => logger.warn("[sync] Academic self-heal push failed", err)); } interface RoleSubgroup { @@ -179,19 +177,26 @@ interface RoleSubgroup { name?: string; } -/** Upserts a row in the `classes` table and returns its Supabase UUID + display name. */ +/** Upserts a row in the `classes` table and returns its Supabase UUID + display name. + * + * Identity: external_group_id = programme_config_group_id (stable across semesters). + * Name: usergroup.name = programme name — truly immutable (e.g. "Computer Science and Business Systems"). + * Using regular ON CONFLICT DO UPDATE so the first sync after this change + * corrects any previously-written stale names (e.g. "CB2 2025-2029"). + * Subsequent syncs are idempotent because usergroup.name never changes. + */ async function upsertClass( supabaseAdmin: ReturnType, - externalId: string, + externalId: string | number, name: string, ): Promise<{ id: string; name: string } | null> { const { data, error } = await supabaseAdmin .from("classes") .upsert({ external_group_id: externalId, name }, { onConflict: "external_group_id" }) - .select("id") + .select("id, name") .single(); if (error || !data) return null; - return { id: data.id, name }; + return { id: data.id, name: data.name }; } async function detectAndSyncClass( @@ -200,36 +205,56 @@ async function detectAndSyncClass( ): Promise<{ classId: string | null; classInfo: { id: string; name: string } | null }> { const supabaseAdmin = getAdminClient(); - const safeGet = (obj: unknown, prop: string) => obj && typeof obj === "object" ? Reflect.get(obj, prop) : undefined; const rolesObj = safeGet(rolesData, "data") ?? rolesData; const subgroupRoles = Array.isArray(safeGet(rolesObj, "subgroupRoles")) ? (safeGet(rolesObj, "subgroupRoles") as RoleSubgroup[]) : []; // Priority 1: course usersubgroup. - // Identity preference: - // 1. programme_config_group_id — section-level, confirmed stable across - // consecutive semesters (S1 id=9888 → S2 id=11509, pcg stayed 710). - // 2. usergroup.id — programme-level fallback when pcg is absent. - const courseWithGroup = coursesList.find( - c => c.usersubgroup?.programme_config_group_id != null - || c.usersubgroup?.usergroup?.id != null, + // + // Identity (external_group_id): programme_config_group_id + // - Confirmed stable across consecutive semesters in real EzyGo data + // (usersubgroup.id changed 9888→11509 between S1/S2, pcg stayed 710). + // - Falls back to usergroup.id if pcg is absent. + // - Do NOT change this to usergroup.id — existing DB rows are keyed by pcg + // and changing would orphan class_courses for all current users. + // + // Display name (classes.name): usergroup.name + // - "Computer Science and Business Systems" — the programme name, truly immutable. + // - NOT usersubgroup.name ("CB2 2025-2029", "CU12025-2029...") which changes + // every semester when the admin creates a new usersubgroup for the new term. + // - Upsert uses ON CONFLICT DO UPDATE so the first sync after this change + // corrects any stale names already in the database. + // Subsequent syncs are idempotent since usergroup.name never changes. + // Find the best course with both stable group ID and usergroup details populated + let courseWithGroup = coursesList.find( + c => (c.usersubgroup?.programme_config_group_id != null || c.usersubgroup?.usergroup?.id != null) + && c.usersubgroup?.usergroup?.name != null ); + // If no course has usergroup details populated, fall back to any course with a stable ID + if (!courseWithGroup) { + courseWithGroup = coursesList.find( + c => c.usersubgroup?.programme_config_group_id != null + || c.usersubgroup?.usergroup?.id != null + ); + } + if (courseWithGroup?.usersubgroup) { const sub = courseWithGroup.usersubgroup; const stableId = sub.programme_config_group_id ?? sub.usergroup?.id; - const displayName = sub.name ?? sub.usergroup?.name ?? ""; - if (stableId != null && displayName) { - const classInfo = await upsertClass(supabaseAdmin, String(stableId), displayName); + const stableName = sub.usergroup?.name ?? (stableId != null ? String(stableId) : ""); + if (stableId != null && stableName) { + const classInfo = await upsertClass(supabaseAdmin, stableId, stableName); if (classInfo) return { classId: classInfo.id, classInfo }; } } // Priority 2: subgroupRoles fallback (institutionuser/myroles). const primarySubgroup = subgroupRoles[0]; - if (primarySubgroup?.name && primarySubgroup.id) { - const classInfo = await upsertClass(supabaseAdmin, String(primarySubgroup.id), primarySubgroup.name); + if (primarySubgroup?.id) { + const fallbackName = String(primarySubgroup.id); + const classInfo = await upsertClass(supabaseAdmin, String(primarySubgroup.id), fallbackName); if (classInfo) return { classId: classInfo.id, classInfo }; } @@ -275,21 +300,7 @@ async function populateCourseCatalogAndMigrateTrackers( } } -async function migrateClassCourses( - supabaseAdmin: ReturnType, - classId: string | null, - oldClassId: string | null | undefined, - authId: string, -): Promise { - if (!classId || !oldClassId || oldClassId === classId) return; - const { error: migrateError } = await supabaseAdmin - .from("class_courses") - .update({ class_id: classId }) - .eq("class_id", oldClassId); - if (migrateError) { - logger.warn(`[sync] class_courses migration skipped for ${redact("id", authId)}: ${migrateError.message}`); - } -} + async function detectClassAndPopulateCatalog( coursesList: CourseItem[], @@ -377,6 +388,115 @@ function resolveMergedProfile( }; } +export interface LightSyncResult { + academic: { + year: string | null; + semester: "even" | "odd" | null; + current_year: string; + current_semester: string; + }; +} + +export interface FullSyncResult extends LightSyncResult { + id: string; + class: { id: string; name: string } | null; + profile: { + firstName: string | null; + lastName: string | null; + username: string | null; + email: string | null; + phone: string | null; + gender: string | null; + birthDate: string | null; + lastSyncedAt: string | null; + }; + courses: Record; + terms_version: string | null; + updated: boolean; +} + +export type SyncResult = FullSyncResult | LightSyncResult; + +async function fetchAcademicAndProfileData( + token: string, + fullSync: boolean +): Promise<[Response | undefined, unknown, unknown]> { + if (fullSync) { + return Promise.all([ + egressFetch("myprofile", { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }), + egressFetch("user/setting/default_semester", { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }).then(safeEzygoJson), + egressFetch("user/setting/default_academic_year", { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }).then(safeEzygoJson), + ]); + } + + const [semRaw, yearRaw] = await Promise.all([ + egressFetch("user/setting/default_semester", { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }).then(safeEzygoJson), + egressFetch("user/setting/default_academic_year", { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }).then(safeEzygoJson), + ]); + return [undefined, semRaw, yearRaw]; +} + +async function parseProfileResponse( + ezygoRes: Response | undefined, + ezygoId: string +): Promise<{ ezygoData: EzygoProfileResponse; resolvedEzygoId: string }> { + if (!ezygoRes || !ezygoRes.ok) { + throw new Error(`EzyGo Profile failed: ${ezygoRes?.status}`); + } + + const json = await safeResponseJson<{ data?: EzygoProfileResponse } | EzygoProfileResponse>(ezygoRes); + if (!json) { + throw new Error(`EzyGo Profile returned empty or invalid JSON: ${ezygoRes?.status}`); + } + const ezygoData = (safeGet(json, "data") ?? json) as EzygoProfileResponse; + + const resolvedEzygoId = (ezygoId && String(ezygoId).trim() !== "") + ? String(ezygoId) + : String(ezygoData.user_id || ezygoData.user?.id || ""); + + if (!resolvedEzygoId) { + throw new Error("Missing EzyGo User ID (local and remote)"); + } + + return { ezygoData, resolvedEzygoId }; +} + +export async function performProfileSync( + token: string, + ezygoId: string, + authId: string, + fullSync: false +): Promise; + +export async function performProfileSync( + token: string, + ezygoId: string, + authId: string, + fullSync?: true +): Promise; + +export async function performProfileSync( + token: string, + ezygoId: string, + authId: string, + fullSync?: boolean +): Promise; + /** * Centrally performs a full profile sync from EzyGo to Supabase. * Handles Name, Email, Phone, Gender, Birth Date, and Academic Context. @@ -385,24 +505,41 @@ export async function performProfileSync( token: string, ezygoId: string, authId: string, -) { + fullSync: boolean = true +): Promise { const supabaseAdmin = getAdminClient(); try { - const [ezygoRes, semRaw, yearRaw, coursesRes, rolesData] = await Promise - .all([ - egressFetch("myprofile", { - headers: { Authorization: `Bearer ${token}` }, - cache: "no-store", - }), - egressFetch("user/setting/default_semester", { - headers: { Authorization: `Bearer ${token}` }, - cache: "no-store", - }).then(safeEzygoJson), - egressFetch("user/setting/default_academic_year", { - headers: { Authorization: `Bearer ${token}` }, - cache: "no-store", - }).then(safeEzygoJson), + // Step 1: Fetch Profile and Academic Context + const [ezygoRes, semRaw, yearRaw] = await fetchAcademicAndProfileData(token, fullSync); + + const { ezygoAcademicSemester, ezygoAcademicYear, currentAcademic } = resolveAcademicContext(semRaw, yearRaw); + + // Step 2: Self-heal academic context if missing, and WAIT for it to finish + await triggerAcademicSelfHeal(token, authId, ezygoAcademicSemester, ezygoAcademicYear, currentAcademic); + + if (!fullSync) { + // Fast path: if only a light sync is requested, we just return the academic context. + // The backend route only uses `syncResult.academic` in this scenario. + return { + academic: { + year: ezygoAcademicYear, + semester: ezygoAcademicSemester, + current_year: currentAcademic.current_year, + current_semester: currentAcademic.current_semester, + } + }; + } + + const { ezygoData, resolvedEzygoId } = await parseProfileResponse(ezygoRes, ezygoId); + + let classId: string | null = null; + let classInfo: { id: string; name: string } | null = null; + let coursesMap: Record = {}; + + if (fullSync) { + // Step 3: Now fetch courses and roles (which depend on the healed semester) + const [coursesRes, rolesData] = await Promise.all([ egressFetch("institutionuser/courses/withusers", { headers: { Authorization: `Bearer ${token}` }, cache: "no-store", @@ -413,51 +550,21 @@ export async function performProfileSync( }).then(safeResponseJson), ]); - if (!ezygoRes.ok) { - throw new Error(`EzyGo Profile failed: ${ezygoRes.status}`); - } - - const json = await safeResponseJson<{ data?: EzygoProfileResponse } | EzygoProfileResponse>(ezygoRes); - if (!json) { - throw new Error(`EzyGo Profile returned empty or invalid JSON: ${ezygoRes.status}`); + const processed = await processCoursesData(coursesRes); + coursesMap = processed.coursesMap; + const detection = await detectClassAndPopulateCatalog(processed.coursesList, rolesData, authId); + classId = detection.classId; + classInfo = detection.classInfo; } - const safeGet = (obj: unknown, prop: string) => obj && typeof obj === "object" ? Reflect.get(obj, prop) : undefined; - const ezygoData: EzygoProfileResponse = (safeGet(json, "data") ?? json) as EzygoProfileResponse; - - const resolvedEzygoId = (ezygoId && String(ezygoId).trim() !== "") - ? String(ezygoId) - : String(ezygoData.user_id || ezygoData.user?.id || ""); - - if (!resolvedEzygoId) { - throw new Error("Missing EzyGo User ID (local and remote)"); - } - - const { coursesMap, coursesList } = await processCoursesData(coursesRes); - - const { ezygoAcademicSemester, ezygoAcademicYear, currentAcademic } = resolveAcademicContext(semRaw, yearRaw); - - triggerAcademicSelfHeal(token, authId, ezygoAcademicSemester, ezygoAcademicYear, currentAcademic); - - const { classId, classInfo } = await detectClassAndPopulateCatalog(coursesList, rolesData, authId); const { data: existingUser } = await supabaseAdmin .from("users") .select( - "first_name, last_name, phone, phone_iv, gender, gender_iv, birth_date, birth_date_iv, terms_version, class_id", + "first_name, last_name, phone, phone_iv, gender, gender_iv, birth_date, birth_date_iv, terms_version, class_id, last_synced_at", ) .or(`id.eq.${resolvedEzygoId},auth_id.eq.${authId}`) .maybeSingle(); - // When the stable class ID changes (programme_config_group_id replaces the old - // semester-scoped usersubgroup.id), forward-migrate class_courses so existing - // manually-added courses are not orphaned on the old class row. - // class_courses is class-scoped (not user-scoped): all members of a class share - // the same rows, so migrating the entire set for the old class UUID to the new one - // is correct and idempotent — subsequent syncs by other members of the same cohort - // will find no rows remaining on the old class_id and perform a safe no-op. - const oldClassId = existingUser?.class_id; - await migrateClassCourses(supabaseAdmin, classId, oldClassId, authId); - const { mergedFirst, mergedLast, mergedPhone, mergedGender, mergedBirthDate } = resolveMergedProfile(existingUser, ezygoData); const encPhone = mergedPhone ? encrypt(mergedPhone) : null; @@ -466,9 +573,8 @@ export async function performProfileSync( const upsertUsername = ezygoData.username ?? ezygoData.user?.username ?? null; const upsertEmail = ezygoData.email ?? ezygoData.user?.email ?? null; - const upsertLastSyncedAt = new Date().toISOString(); - const upsertData = { + const upsertData: Record = { id: resolvedEzygoId, auth_id: authId, username: upsertUsername, @@ -481,11 +587,14 @@ export async function performProfileSync( gender_iv: encGender?.iv ?? null, birth_date: encBirthDate?.content ?? null, birth_date_iv: encBirthDate?.iv ?? null, - last_synced_at: upsertLastSyncedAt, ezygo_created_at: safeGet(ezygoData, "created_at") ? String(safeGet(ezygoData, "created_at")) : null, class_id: classId || existingUser?.class_id || null, }; + if (fullSync) { + upsertData.last_synced_at = new Date().toISOString(); + } + const { error: upsertError } = await supabaseAdmin .from("users") .upsert(upsertData, { onConflict: "id" }); @@ -508,7 +617,7 @@ export async function performProfileSync( phone: mergedPhone, gender: mergedGender, birthDate: mergedBirthDate, - lastSyncedAt: upsertLastSyncedAt, + lastSyncedAt: (upsertData.last_synced_at as string) || existingUser?.last_synced_at || null, }, academic: { year: ezygoAcademicYear, diff --git a/src/proxy.ts b/src/proxy.ts index 27004d3f..28759c09 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -289,7 +289,7 @@ export async function proxy(request: NextRequest) { response.headers.set("x-nonce", nonce); // Initialize Supabase - const isProd = process.env.NODE_ENV === "production"; + 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; diff --git a/supabase/migrations/20260521000000_harden_academic_year_null_checks.sql b/supabase/migrations/20260521000000_harden_academic_year_null_checks.sql new file mode 100644 index 00000000..9b04da5f --- /dev/null +++ b/supabase/migrations/20260521000000_harden_academic_year_null_checks.sql @@ -0,0 +1,48 @@ +-- Harden class_courses and course_instructors tables to prevent nulls and empty strings. +-- +-- This migration: +-- 1. Adds strict non-null constraints and formats to academic_year (supporting YYYY-YYYY or YYYY-YY). +-- 2. Ensures course_code and course_name cannot be empty strings. + +-- ============================================================================ +-- 1. class_courses Hardening +-- ============================================================================ + +-- Ensure academic_year is NOT NULL and strictly matches YYYY-YYYY or YYYY-YY format +ALTER TABLE "public"."class_courses" + ADD CONSTRAINT "class_courses_academic_year_check" + CHECK ("academic_year" IS NOT NULL AND "academic_year" ~ '^[0-9]{4}-([0-9]{4}|[0-9]{2})$') NOT VALID; + +ALTER TABLE "public"."class_courses" VALIDATE CONSTRAINT "class_courses_academic_year_check"; + +-- Ensure course_code and course_name are not empty or blank strings +ALTER TABLE "public"."class_courses" + ADD CONSTRAINT "class_courses_course_code_not_empty" + CHECK (btrim("course_code") <> '') NOT VALID, + ADD CONSTRAINT "class_courses_course_name_not_empty" + CHECK (btrim("course_name") <> '') NOT VALID; + +ALTER TABLE "public"."class_courses" VALIDATE CONSTRAINT "class_courses_course_code_not_empty"; +ALTER TABLE "public"."class_courses" VALIDATE CONSTRAINT "class_courses_course_name_not_empty"; + + +-- ============================================================================ +-- 2. course_instructors Hardening +-- ============================================================================ + +-- Ensure academic_year strictly matches YYYY-YYYY or YYYY-YY format +ALTER TABLE "public"."course_instructors" + ADD CONSTRAINT "course_instructors_academic_year_check" + CHECK ("academic_year" IS NOT NULL AND "academic_year" ~ '^[0-9]{4}-([0-9]{4}|[0-9]{2})$') NOT VALID; + +ALTER TABLE "public"."course_instructors" VALIDATE CONSTRAINT "course_instructors_academic_year_check"; + +-- Ensure course_code and instructor_name are not empty or blank strings +ALTER TABLE "public"."course_instructors" + ADD CONSTRAINT "course_instructors_course_code_not_empty" + CHECK (btrim("course_code") <> '') NOT VALID, + ADD CONSTRAINT "course_instructors_instructor_name_not_empty" + CHECK (btrim("instructor_name") <> '') NOT VALID; + +ALTER TABLE "public"."course_instructors" VALIDATE CONSTRAINT "course_instructors_course_code_not_empty"; +ALTER TABLE "public"."course_instructors" VALIDATE CONSTRAINT "course_instructors_instructor_name_not_empty"; diff --git a/supabase/migrations/20260522000000_harden_tracker_year_constraint_and_rls.sql b/supabase/migrations/20260522000000_harden_tracker_year_constraint_and_rls.sql new file mode 100644 index 00000000..97f95d0a --- /dev/null +++ b/supabase/migrations/20260522000000_harden_tracker_year_constraint_and_rls.sql @@ -0,0 +1,28 @@ +-- Harden tracker table constraints and RLS to ensure uniformity and completeness. +-- +-- This migration: +-- 1. Enforces academic year format uniformity on tracker.year column matching other academic tables. +-- 2. Adds complete RLS UPDATE policy on tracker table for authenticated users. + +-- ============================================================================ +-- 1. Enforce Academic Year Format Uniformity on tracker.year +-- ============================================================================ + +-- Ensure tracker.year strictly matches YYYY-YYYY or YYYY-YY format +ALTER TABLE "public"."tracker" + ADD CONSTRAINT "tracker_year_check" + CHECK ("year" ~ '^[0-9]{4}-([0-9]{4}|[0-9]{2})$') NOT VALID; + +ALTER TABLE "public"."tracker" VALIDATE CONSTRAINT "tracker_year_check"; + +-- ============================================================================ +-- 2. Complete RLS UPDATE Policy on public.tracker +-- ============================================================================ + +DROP POLICY IF EXISTS "Users can update own tracker" ON "public"."tracker"; + +CREATE POLICY "Users can update own tracker" + ON "public"."tracker" + FOR UPDATE TO authenticated + USING (auth_user_id = auth.uid()) + WITH CHECK (auth_user_id = auth.uid());