diff --git a/backend/src/controllers/notificationController.js b/backend/src/controllers/notificationController.js index 4bf1482..bcbcfa8 100644 --- a/backend/src/controllers/notificationController.js +++ b/backend/src/controllers/notificationController.js @@ -2,9 +2,28 @@ const db = require('../config/db'); const fcmService = require('../services/fcmService'); fcmService.init(); +// In-memory debouncer to prevent rapid duplicate notification creation (within 2 seconds) +const _recentCreates = new Map(); + // Programmatic helper to create notifications (can be called from other controllers) // category: 'student' | 'document' | 'user' | 'system' exports.createNotification = (userId, title, message, category = 'system', entityType = null, entityId = null) => { + // Deduplication check: suppress identical notifications fired within 2000ms + const dedupeKey = `${userId || 'all'}_${title}_${message}_${entityType || ''}_${entityId || ''}`; + const now = Date.now(); + const lastCreated = _recentCreates.get(dedupeKey); + if (lastCreated && (now - lastCreated < 2000)) { + return; // Suppress duplicate call + } + _recentCreates.set(dedupeKey, now); + + // Evict old cache entries periodically + if (_recentCreates.size > 200) { + for (const [k, t] of _recentCreates.entries()) { + if (now - t > 10000) _recentCreates.delete(k); + } + } + let notifId = null; try { const result = db.prepare('INSERT INTO notifications (user_id, title, message, is_read, category, entity_type, entity_id) VALUES (?, ?, ?, 0, ?, ?, ?)') @@ -44,6 +63,18 @@ exports.registerFcmToken = (req, res) => { SET user_id = excluded.user_id, updated_at = excluded.updated_at `).run(req.user.id, token); + + // Prune stale tokens: keep only the 3 most recently updated tokens for this user + db.prepare(` + DELETE FROM fcm_tokens + WHERE user_id = ? AND id NOT IN ( + SELECT id FROM fcm_tokens + WHERE user_id = ? + ORDER BY updated_at DESC, id DESC + LIMIT 3 + ) + `).run(req.user.id, req.user.id); + res.json({ message: 'FCM token registered' }); } catch (error) { res.status(500).json({ message: 'Failed to register FCM token', error: error.message }); diff --git a/backend/src/services/fcmService.js b/backend/src/services/fcmService.js index 7ccdeec..ced249b 100644 --- a/backend/src/services/fcmService.js +++ b/backend/src/services/fcmService.js @@ -101,7 +101,10 @@ const _sendMulticast = async (tokens, title, body, category = 'system', notifica priority: 'high', defaultSound: true, defaultVibrateTimings: true, - visibility: 'public' + visibility: 'public', + icon: 'ic_launcher_foreground', + clickAction: 'FLUTTER_NOTIFICATION_CLICK', + tag: notificationId ? String(notificationId) : undefined } } }); @@ -188,9 +191,16 @@ const sendNotification = async ({ userId = null, title, body, category = 'system WHERE ts.section_id = ? AND u.role = 'teacher' AND u.is_active = 1 `).all(entityId).map(r => r.token); tokens.push(...teacherTokens); + } else if (!entityType) { + // Broadcast to all active teachers for unscoped system notifications + const allTeacherTokens = db.prepare(` + SELECT ft.token + FROM fcm_tokens ft + JOIN users u ON ft.user_id = u.id + WHERE u.role = 'teacher' AND u.is_active = 1 + `).all().map(r => r.token); + tokens.push(...allTeacherTokens); } - // Note: Unscoped notifications (entityType is null or not student/document/section) - // are delivered solely to Admins and not broadcast to teachers. } await _sendMulticast(tokens, title, body, category, notificationId); diff --git a/frontend/android/app/src/main/AndroidManifest.xml b/frontend/android/app/src/main/AndroidManifest.xml index ebe5bc4..f5c5156 100644 --- a/frontend/android/app/src/main/AndroidManifest.xml +++ b/frontend/android/app/src/main/AndroidManifest.xml @@ -27,6 +27,12 @@ + + diff --git a/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/AlarmReceiver.kt b/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/AlarmReceiver.kt index 2053329..455e115 100644 --- a/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/AlarmReceiver.kt +++ b/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/AlarmReceiver.kt @@ -68,8 +68,20 @@ class AlarmReceiver : BroadcastReceiver() { if (list.length() == 0) return val highestOldId = prefs.getInt(KEY_LAST_ID, 0) - var newHighestId = highestOldId + if (highestOldId == 0) { + // Seed KEY_LAST_ID on initial check to avoid spamming past notifications + var maxId = 0 + for (i in 0 until list.length()) { + val id = list.getJSONObject(i).optInt("id", 0) + if (id > maxId) maxId = id + } + if (maxId > 0) { + prefs.edit().putInt(KEY_LAST_ID, maxId).apply() + } + return + } + var newHighestId = highestOldId ensureChannel(context) val notifManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -81,8 +93,7 @@ class AlarmReceiver : BroadcastReceiver() { val isReadRaw = item.opt("is_read") val isRead = isReadRaw == true || isReadRaw == 1 - val isNew = if (highestOldId == 0) !isRead else (id > highestOldId && !isRead) - if (!isNew) continue + if (id <= highestOldId || isRead) continue val title = item.optString("title", "TIS RMS Notification") val message = item.optString("message", "") @@ -97,7 +108,7 @@ class AlarmReceiver : BroadcastReceiver() { ) val notification = Notification.Builder(context, CHANNEL_ID) - .setSmallIcon(R.mipmap.ic_launcher) + .setSmallIcon(R.drawable.ic_launcher_foreground) .setContentTitle(title) .setContentText(message) .setAutoCancel(true) diff --git a/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/MainActivity.kt b/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/MainActivity.kt index 9bc8263..e9b4118 100644 --- a/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/MainActivity.kt +++ b/frontend/android/app/src/main/kotlin/com/teamsb/tis_rms/MainActivity.kt @@ -1,11 +1,32 @@ package com.teamsb.tis_rms +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build import android.os.Bundle import io.flutter.embedding.android.FlutterActivity class MainActivity : FlutterActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + ensureNotificationChannel() AlarmScheduler.schedule(this) } + + private fun ensureNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channelId = "tis_rms_activities_channel" + val channelName = "Recent Activities" + val channelDescription = "Notifications for recent activities and system events" + val importance = NotificationManager.IMPORTANCE_HIGH + val channel = NotificationChannel(channelId, channelName, importance).apply { + description = channelDescription + enableVibration(true) + } + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + } } + diff --git a/frontend/lib/core/services/background_sync_service.dart b/frontend/lib/core/services/background_sync_service.dart index 8525658..e170e59 100644 --- a/frontend/lib/core/services/background_sync_service.dart +++ b/frontend/lib/core/services/background_sync_service.dart @@ -46,13 +46,20 @@ void callbackDispatcher() { // 3. Find the last seen notification ID final highestOldId = prefs.getInt('last_seen_notification_id') ?? 0; + // On first run, seed last_seen_notification_id to latest ID to prevent alert storms of past items + if (highestOldId == 0) { + final ids = list.map((e) => e['id'] as int? ?? 0).toList(); + if (ids.isNotEmpty) { + final newHighestId = ids.reduce(math.max); + await prefs.setInt('last_seen_notification_id', newHighestId); + } + return Future.value(true); + } + // Find new unread notifications final newNotes = list.where((e) { final id = e['id'] as int? ?? 0; final isRead = (e['is_read'] == 1 || e['is_read'] == true); - if (highestOldId == 0) { - return !isRead; - } return id > highestOldId && !isRead; }).toList(); diff --git a/frontend/lib/core/services/fcm_service.dart b/frontend/lib/core/services/fcm_service.dart index 156a0d5..d041f3b 100644 --- a/frontend/lib/core/services/fcm_service.dart +++ b/frontend/lib/core/services/fcm_service.dart @@ -14,22 +14,34 @@ import 'notification_service.dart'; Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { try { await Firebase.initializeApp(); - final title = message.notification?.title ?? - message.data['title']?.toString() ?? - 'TIS RMS'; - final body = message.notification?.body ?? - message.data['body']?.toString() ?? - ''; - int? notifId; - if (message.data['id'] != null && message.data['id'].toString().isNotEmpty) { - notifId = int.tryParse(message.data['id'].toString()); + final notifIdStr = message.data['id']?.toString(); + int? notifId = (notifIdStr != null && notifIdStr.isNotEmpty) + ? int.tryParse(notifIdStr) + : null; + + // Advance last_seen_notification_id so AlarmReceiver and Workmanager won't duplicate it + if (notifId != null) { + try { + final prefs = await SharedPreferences.getInstance(); + final currentLastId = prefs.getInt('last_seen_notification_id') ?? 0; + if (notifId > currentLastId) { + await prefs.setInt('last_seen_notification_id', notifId); + } + } catch (_) {} } - if (body.isNotEmpty) { - await NotificationService().showNotification( - id: notifId, - title: title, - body: body, - ); + + // On Android, if message.notification != null, Google Play Services has already + // presented the notification in the tray. Only present manually for data-only messages. + if (message.notification == null) { + final title = message.data['title']?.toString() ?? 'TIS RMS'; + final body = message.data['body']?.toString() ?? ''; + if (body.isNotEmpty) { + await NotificationService().showNotification( + id: notifId, + title: title, + body: body, + ); + } } } catch (e) { debugPrint('[FcmBackground] Handler error: $e'); @@ -54,16 +66,28 @@ class FcmService { // Foreground: show local notification banner (with deduplication) FirebaseMessaging.onMessage.listen((RemoteMessage message) async { + final notifIdStr = message.data['id']?.toString(); + int? notifId = (notifIdStr != null && notifIdStr.isNotEmpty) + ? int.tryParse(notifIdStr) + : null; + + // Advance last_seen_notification_id so polling or background sync won't repeat it + if (notifId != null) { + try { + final prefs = await SharedPreferences.getInstance(); + final currentLastId = prefs.getInt('last_seen_notification_id') ?? 0; + if (notifId > currentLastId) { + await prefs.setInt('last_seen_notification_id', notifId); + } + } catch (_) {} + } + final title = message.notification?.title ?? message.data['title']?.toString() ?? 'TIS RMS'; final body = message.notification?.body ?? message.data['body']?.toString() ?? ''; - int? notifId; - if (message.data['id'] != null && message.data['id'].toString().isNotEmpty) { - notifId = int.tryParse(message.data['id'].toString()); - } if (body.isNotEmpty) { await NotificationService().showNotification( id: notifId, @@ -72,6 +96,9 @@ class FcmService { ); } }); + + // Eagerly register token with backend on startup if an active session exists + registerToken(); } catch (e) { debugPrint('[FcmService] Init error: $e'); } @@ -96,13 +123,13 @@ class FcmService { final dio = Dio(BaseOptions( baseUrl: baseUrl, - connectTimeout: const Duration(seconds: 5), - receiveTimeout: const Duration(seconds: 5), + connectTimeout: const Duration(seconds: 8), + receiveTimeout: const Duration(seconds: 8), headers: {'Authorization': 'Bearer $jwtToken'}, )); await dio.post('/notifications/fcm-token', data: {'token': token}); - debugPrint('[FcmService] Token registered'); + debugPrint('[FcmService] FCM token registered successfully with backend'); } catch (e) { debugPrint('[FcmService] Token registration failed: $e'); } diff --git a/frontend/lib/core/services/notification_service.dart b/frontend/lib/core/services/notification_service.dart index b42a761..380050c 100644 --- a/frontend/lib/core/services/notification_service.dart +++ b/frontend/lib/core/services/notification_service.dart @@ -1,5 +1,6 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/foundation.dart' show kIsWeb, debugPrint; +import 'package:shared_preferences/shared_preferences.dart'; import 'dart:io' show Platform; class NotificationService { @@ -55,7 +56,6 @@ class NotificationService { .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >(); - await androidImplementation?.requestNotificationsPermission(); const AndroidNotificationChannel channel = AndroidNotificationChannel( 'tis_rms_activities_channel', @@ -66,6 +66,10 @@ class NotificationService { enableVibration: true, ); await androidImplementation?.createNotificationChannel(channel); + + try { + await androidImplementation?.requestNotificationsPermission(); + } catch (_) {} } } catch (_) {} @@ -84,17 +88,32 @@ class NotificationService { if (!_initialized) await initialize(); - // Deduplication check (prevents duplicate triggers from FCM + Polling + Stream) - final dedupeKey = '${id ?? ''}_${title.trim()}_${body.trim()}'; + // Deduplication check: key primarily by notification ID if available, otherwise by content + final dedupeKey = id != null + ? 'id_$id' + : 'msg_${title.trim()}_${body.trim()}'; + if (_recentlyShown.contains(dedupeKey)) { + debugPrint('[NotificationService] Suppressed duplicate notification: $dedupeKey'); return; } _recentlyShown.add(dedupeKey); - // Evict after 15 seconds - Future.delayed(const Duration(seconds: 15), () { + // Evict after 60 seconds (covers rapid polling cycles and AlarmManager checks) + Future.delayed(const Duration(seconds: 60), () { _recentlyShown.remove(dedupeKey); }); + // Advance last_seen_notification_id in SharedPreferences + if (id != null) { + try { + final prefs = await SharedPreferences.getInstance(); + final current = prefs.getInt('last_seen_notification_id') ?? 0; + if (id > current) { + await prefs.setInt('last_seen_notification_id', id); + } + } catch (_) {} + } + const AndroidNotificationDetails androidPlatformChannelSpecifics = AndroidNotificationDetails( 'tis_rms_activities_channel', // id