From 0a8e388757a4ff652a08d5295831c028b549bd4b Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Sat, 28 Feb 2026 16:19:08 +0800 Subject: [PATCH] Move push notification to client instead of db trigger --- lib/main.dart | 365 ++++++++++++------ lib/providers/notifications_provider.dart | 44 +-- lib/providers/tasks_provider.dart | 259 +++++++++++-- lib/providers/tickets_provider.dart | 56 +++ supabase/functions/send_fcm/index.ts | 140 ++++--- ...8120000_enforce_fcm_device_id_not_null.sql | 24 ++ ...20260228150000_add_notification_pushes.sql | 20 + ...60228153000_drop_notifications_trigger.sql | 16 + 8 files changed, 698 insertions(+), 226 deletions(-) create mode 100644 supabase/migrations/20260228120000_enforce_fcm_device_id_not_null.sql create mode 100644 supabase/migrations/20260228150000_add_notification_pushes.sql create mode 100644 supabase/migrations/20260228153000_drop_notifications_trigger.sql diff --git a/lib/main.dart b/lib/main.dart index 4e5e1807..ceea2377 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart' show kIsWeb; -import 'package:audioplayers/audioplayers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdfrx/pdfrx.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -9,8 +8,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'firebase_options.dart'; -import 'providers/profile_provider.dart'; -import 'models/profile.dart'; +// removed unused imports import 'app.dart'; import 'providers/notifications_provider.dart'; import 'utils/app_time.dart'; @@ -21,37 +19,68 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; -final AudioPlayer _player = AudioPlayer(); +// audio player not used at top-level; instantiate where needed +StreamSubscription? _fcmTokenRefreshSub; Map _formatNotificationFromData(Map data) { - final actor = - (data['actor_name'] ?? - data['mentioner_name'] ?? - data['user_name'] ?? - data['from'] ?? - 'Someone') - .toString(); - final taskId = data['task_id'] ?? data['taskId'] ?? data['task'] ?? ''; + String actor = ''; + if (data['actor_name'] != null) { + actor = data['actor_name'].toString(); + } else if (data['mentioner_name'] != null) { + actor = data['mentioner_name'].toString(); + } else if (data['user_name'] != null) { + actor = data['user_name'].toString(); + } else if (data['from'] != null) { + actor = data['from'].toString(); + } else if (data['actor'] != null) { + final a = data['actor']; + if (a is Map && a['name'] != null) { + actor = a['name'].toString(); + } else if (a is String) { + try { + final parsed = jsonDecode(a); + if (parsed is Map && parsed['name'] != null) { + actor = parsed['name'].toString(); + } + } catch (_) { + // ignore JSON parse errors + } + } + } + if (actor.isEmpty) { + actor = 'Someone'; + } + + final taskNumber = + (data['task_number'] ?? data['taskNumber'] ?? data['task_no']) + ?.toString() ?? + ''; + final taskId = + (data['task_id'] ?? data['taskId'] ?? data['task'])?.toString() ?? ''; final ticketId = data['ticket_id'] ?? data['ticketId'] ?? data['ticket'] ?? ''; final type = (data['type'] ?? '').toString().toLowerCase(); - if (taskId.isNotEmpty && + final taskLabel = taskNumber.isNotEmpty + ? 'Task $taskNumber' + : (taskId.isNotEmpty ? 'Task #$taskId' : 'Task'); + + if ((taskId.isNotEmpty || taskNumber.isNotEmpty) && (type.contains('assign') || data['action'] == 'assign' || data['assigned'] == 'true')) { return { 'title': 'Task assigned', - 'body': '$actor has assigned you a Task #$taskId', + 'body': '$actor has assigned you $taskLabel', }; } - if (taskId.isNotEmpty && + if ((taskId.isNotEmpty || taskNumber.isNotEmpty) && (type.contains('mention') || data['action'] == 'mention' || data['mentioned'] == 'true')) { return { 'title': 'Mention', - 'body': '$actor has mentioned you in Task #$taskId', + 'body': '$actor has mentioned you in $taskLabel', }; } @@ -86,10 +115,51 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { final String body = formatted['body']!; // Determine a stable ID for deduplication (prefer server-provided id) - final String stableId = - message.data['notification_id'] ?? - message.messageId ?? - (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString(); + String? stableId = + (message.data['notification_id'] as String?) ?? message.messageId; + if (stableId == null) { + final sb = StringBuffer(); + final taskNumber = + (message.data['task_number'] ?? + message.data['taskNumber'] ?? + message.data['task_no']) + ?.toString(); + final taskId = + (message.data['task_id'] ?? + message.data['taskId'] ?? + message.data['task']) + ?.toString(); + final ticketId = + (message.data['ticket_id'] ?? + message.data['ticketId'] ?? + message.data['ticket']) + ?.toString(); + final type = (message.data['type'] ?? '').toString(); + final actorId = + (message.data['actor_id'] ?? + message.data['actorId'] ?? + message.data['actor']) + ?.toString(); + if (taskNumber != null && taskNumber.isNotEmpty) + sb.write('tasknum:$taskNumber'); + else if (taskId != null && taskId.isNotEmpty) + sb.write('task:$taskId'); + if (ticketId != null && ticketId.isNotEmpty) { + if (sb.isNotEmpty) sb.write('|'); + sb.write('ticket:$ticketId'); + } + if (type.isNotEmpty) { + if (sb.isNotEmpty) sb.write('|'); + sb.write('type:$type'); + } + if (actorId != null && actorId.isNotEmpty) { + if (sb.isNotEmpty) sb.write('|'); + sb.write('actor:$actorId'); + } + stableId = sb.isNotEmpty + ? sb.toString() + : (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString(); + } // Dedupe: keep a short-lived cache of recent notification IDs to avoid duplicates try { @@ -182,8 +252,24 @@ Future main() async { if (token == null) return; final ctrl = NotificationsController(supaClient); if (event == AuthChangeEvent.signedIn) { + // register current token and ensure we listen for refreshes await ctrl.registerFcmToken(token); + try { + // cancel any previous subscription + await _fcmTokenRefreshSub?.cancel(); + } catch (_) {} + _fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen(( + t, + ) { + debugPrint('token refreshed (auth listener): $t'); + ctrl.registerFcmToken(t); + }); } else if (event == AuthChangeEvent.signedOut) { + // cancel token refresh subscription and unregister + try { + await _fcmTokenRefreshSub?.cancel(); + } catch (_) {} + _fcmTokenRefreshSub = null; await ctrl.unregisterFcmToken(token); } }); @@ -205,11 +291,94 @@ Future main() async { FirebaseMessaging.onMessage.listen((RemoteMessage message) async { // Prefer the data payload and format friendly messages, with dedupe. - final formatted = _formatNotificationFromData(message.data); - final String stableId = - message.data['notification_id'] ?? - message.messageId ?? - (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString(); + // If actor_name is not present but actor_id is, try to resolve the + // display name using the Supabase client (foreground only). + Map dataForFormatting = Map.from( + message.data, + ); + try { + final hasActorName = + (dataForFormatting['actor_name'] ?? + dataForFormatting['mentioner_name'] ?? + dataForFormatting['user_name'] ?? + dataForFormatting['from'] ?? + dataForFormatting['actor']) != + null; + final actorId = + dataForFormatting['actor_id'] ?? + dataForFormatting['actorId'] ?? + dataForFormatting['actor']; + if (!hasActorName && actorId is String && actorId.isNotEmpty) { + try { + final client = Supabase.instance.client; + final res = await client + .from('profiles') + .select('full_name,display_name,name') + .eq('id', actorId) + .maybeSingle(); + if (res != null) { + String? name; + if (res['full_name'] != null) + name = res['full_name'].toString(); + else if (res['display_name'] != null) + name = res['display_name'].toString(); + else if (res['name'] != null) + name = res['name'].toString(); + if (name != null && name.isNotEmpty) + dataForFormatting['actor_name'] = name; + } + } catch (_) { + // ignore lookup failures and fall back to data payload + } + } + } catch (_) {} + + final formatted = _formatNotificationFromData(dataForFormatting); + String? stableId = + (message.data['notification_id'] as String?) ?? message.messageId; + if (stableId == null) { + final sb = StringBuffer(); + final taskNumber = + (message.data['task_number'] ?? + message.data['taskNumber'] ?? + message.data['task_no']) + ?.toString(); + final taskId = + (message.data['task_id'] ?? + message.data['taskId'] ?? + message.data['task']) + ?.toString(); + final ticketId = + (message.data['ticket_id'] ?? + message.data['ticketId'] ?? + message.data['ticket']) + ?.toString(); + final type = (message.data['type'] ?? '').toString(); + final actorId = + (message.data['actor_id'] ?? + message.data['actorId'] ?? + message.data['actor']) + ?.toString(); + if (taskNumber != null && taskNumber.isNotEmpty) + sb.write('tasknum:$taskNumber'); + else if (taskId != null && taskId.isNotEmpty) + sb.write('task:$taskId'); + if (ticketId != null && ticketId.isNotEmpty) { + if (sb.isNotEmpty) sb.write('|'); + sb.write('ticket:$ticketId'); + } + if (type.isNotEmpty) { + if (sb.isNotEmpty) sb.write('|'); + sb.write('type:$type'); + } + if (actorId != null && actorId.isNotEmpty) { + if (sb.isNotEmpty) sb.write('|'); + sb.write('actor:$actorId'); + } + stableId = sb.isNotEmpty + ? sb.toString() + : (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString(); + } try { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString('recent_notifs') ?? '{}'; @@ -285,28 +454,11 @@ Future main() async { ), ); - // Post-startup: register current FCM token without blocking UI. - if (!kIsWeb) { - Future.microtask(() async { - try { - final token = await FirebaseMessaging.instance.getToken().timeout( - const Duration(seconds: 10), - ); - if (token != null && supaClient.auth.currentUser != null) { - debugPrint('post-startup registering FCM token: $token'); - final ctrl = NotificationsController(supaClient); - await ctrl.registerFcmToken(token); - } - } catch (e) { - debugPrint('post-startup FCM token registration failed: $e'); - } - }); - } + // Post-startup registration removed: token registration is handled + // centrally in the auth state change listener to avoid duplicate inserts. } class NotificationSoundObserver extends ProviderObserver { - StreamSubscription? _tokenSub; - @override void didUpdateProvider( ProviderBase provider, @@ -315,95 +467,54 @@ class NotificationSoundObserver extends ProviderObserver { ProviderContainer container, ) { // play sound + show OS notification on unread-count increase - if (provider == unreadNotificationsCountProvider) { - final prev = previousValue as int?; - final next = newValue as int?; - if (prev != null && next != null && next > prev) { - _maybeShowUnreadNotification(next); - } - } + // if (provider == unreadNotificationsCountProvider) { + // final prev = previousValue as int?; + // final next = newValue as int?; + // if (prev != null && next != null && next > prev) { + // _maybeShowUnreadNotification(next); + // } + // } - // when profile changes, register or unregister tokens - if (provider == currentProfileProvider) { - final profile = newValue as Profile?; - final controller = container.read(notificationsControllerProvider); - - if (profile != null) { - // signed in: save current token and keep listening for refreshes - if (!kIsWeb) { - FirebaseMessaging.instance - .getToken() - .then((token) { - if (token != null) { - debugPrint('profile observer registering token: $token'); - controller.registerFcmToken(token); - } - }) - .catchError((e) { - debugPrint('getToken error: $e'); - return null; - }); - _tokenSub = FirebaseMessaging.instance.onTokenRefresh.listen((token) { - debugPrint('token refreshed: $token'); - controller.registerFcmToken(token); - }); - } - } else { - // signed out: unregister whatever token we currently have - _tokenSub?.cancel(); - if (!kIsWeb) { - FirebaseMessaging.instance - .getToken() - .then((token) { - if (token != null) { - debugPrint('profile observer unregistering token: $token'); - controller.unregisterFcmToken(token); - } - }) - .catchError((e) { - debugPrint('getToken error: $e'); - return null; - }); - } - } - } + // Profile changes no longer perform token registration here. + // Token registration is handled centrally in the auth state change listener + // to avoid duplicate DB rows and duplicate deliveries. } } -void _maybeShowUnreadNotification(int next) async { - try { - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString('recent_notifs') ?? '{}'; - final Map recent = jsonDecode(raw); - final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - const int ttl = 60; // seconds - // prune old entries - recent.removeWhere( - (k, v) => (v is int ? v : int.parse(v.toString())) < now - ttl, - ); - // if there is any recent notification (from server or other), skip duplicating - if (recent.isNotEmpty) return; +// void _maybeShowUnreadNotification(int next) async { +// try { +// final prefs = await SharedPreferences.getInstance(); +// final raw = prefs.getString('recent_notifs') ?? '{}'; +// final Map recent = jsonDecode(raw); +// final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; +// const int ttl = 60; // seconds +// // prune old entries +// recent.removeWhere( +// (k, v) => (v is int ? v : int.parse(v.toString())) < now - ttl, +// ); +// // if there is any recent notification (from server or other), skip duplicating +// if (recent.isNotEmpty) return; - // mark a synthetic unread-notif to prevent immediate duplicates - recent['unread_summary'] = now; - await prefs.setString('recent_notifs', jsonEncode(recent)); +// // mark a synthetic unread-notif to prevent immediate duplicates +// recent['unread_summary'] = now; +// await prefs.setString('recent_notifs', jsonEncode(recent)); - _player.play(AssetSource('tasq_notification.wav')); - NotificationService.show( - id: DateTime.now().millisecondsSinceEpoch ~/ 1000, - title: 'New notifications', - body: 'You have $next unread notifications.', - ); - } catch (e) { - // fallback: show notification - _player.play(AssetSource('tasq_notification.wav')); - NotificationService.show( - id: DateTime.now().millisecondsSinceEpoch ~/ 1000, - title: 'New notifications', - body: 'You have $next unread notifications.', - ); - } -} +// _player.play(AssetSource('tasq_notification.wav')); +// NotificationService.show( +// id: DateTime.now().millisecondsSinceEpoch ~/ 1000, +// title: 'New notifications', +// body: 'You have $next unread notifications.', +// ); +// } catch (e) { +// // fallback: show notification +// _player.play(AssetSource('tasq_notification.wav')); +// NotificationService.show( +// id: DateTime.now().millisecondsSinceEpoch ~/ 1000, +// title: 'New notifications', +// body: 'You have $next unread notifications.', +// ); +// } +// } class _MissingConfigApp extends StatelessWidget { const _MissingConfigApp(); diff --git a/lib/providers/notifications_provider.dart b/lib/providers/notifications_provider.dart index d5aa783d..8eb22dd3 100644 --- a/lib/providers/notifications_provider.dart +++ b/lib/providers/notifications_provider.dart @@ -59,15 +59,22 @@ class NotificationsController { ); await _client.from('notifications').insert(rows); - // push notifications are now handled by a database trigger that - // calls the `send_fcm` edge function. We keep the client-side - // `sendPush` method around for manual/integration use, but avoid - // invoking it here to prevent CORS issues on web. + // If target user IDs are provided, invoke client-side push + // flow by calling `sendPush`. We no longer rely on the DB trigger + // to call the edge function. if (targetUserIds == null || targetUserIds.isEmpty) return; - debugPrint( - 'notification rows inserted; server trigger will perform pushes', - ); + debugPrint('notification rows inserted; invoking client push'); + try { + await sendPush( + userIds: targetUserIds, + title: pushTitle ?? '', + body: pushBody ?? '', + data: pushData, + ); + } catch (e) { + debugPrint('sendPush failed: $e'); + } } /// Create a typed notification in the database. This method handles @@ -167,6 +174,10 @@ class NotificationsController { final userId = _client.auth.currentUser?.id; if (userId == null) return; final deviceId = await DeviceId.getId(); + if (deviceId.isEmpty) { + debugPrint('registerFcmToken: device id missing; skipping'); + return; + } // upsert using a unique constraint on (user_id, device_id) so a single // device keeps its token updated without overwriting other devices. final payload = { @@ -176,13 +187,6 @@ class NotificationsController { 'created_at': DateTime.now().toUtc().toIso8601String(), }; final res = await _client.from('fcm_tokens').upsert(payload); - if (res == null) { - debugPrint( - 'registerFcmToken: null response for user=$userId token=$token', - ); - return; - } - final dyn = res as dynamic; if (dyn.error != null) { // duplicate key or RLS issue - just log it @@ -204,12 +208,6 @@ class NotificationsController { .eq('user_id', userId) .eq('device_id', deviceId) .or('token.eq.$token'); - if (res == null) { - debugPrint( - 'unregisterFcmToken: null response for user=$userId token=$token', - ); - return; - } final uDyn = res as dynamic; if (uDyn.error != null) { debugPrint( @@ -227,11 +225,11 @@ class NotificationsController { Map? data, }) async { try { - if (tokens != null) { + if (tokens != null && tokens.isNotEmpty) { debugPrint( 'invoking send_fcm with ${tokens.length} tokens, title="$title"', ); - } else if (userIds != null) { + } else if (userIds != null && userIds.isNotEmpty) { debugPrint( 'invoking send_fcm with userIds=${userIds.length}, title="$title"', ); @@ -239,6 +237,7 @@ class NotificationsController { debugPrint('sendPush called with neither tokens nor userIds'); return; } + final bodyPayload = { 'tokens': tokens ?? [], 'user_ids': userIds ?? [], @@ -246,6 +245,7 @@ class NotificationsController { 'body': body, 'data': data ?? {}, }; + await _client.functions.invoke('send_fcm', body: bodyPayload); } catch (err) { debugPrint('sendPush invocation error: $err'); diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index 6b214e8e..09c9058e 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -396,8 +396,9 @@ class TasksController { taskId = rpcRow['id'] as String?; assignedNumber = rpcRow['task_number'] as String?; } - // ignore: avoid_print - print('createTask via RPC assigned number=$assignedNumber id=$taskId'); + debugPrint( + 'createTask via RPC assigned number=$assignedNumber id=$taskId', + ); } catch (e) { // RPC not available or failed; fallback to client insert with retry const int maxAttempts = 3; @@ -424,8 +425,9 @@ class TasksController { assignedNumber = insertData == null ? null : insertData['task_number'] as String?; - // ignore: avoid_print - print('createTask fallback assigned number=$assignedNumber id=$taskId'); + debugPrint( + 'createTask fallback assigned number=$assignedNumber id=$taskId', + ); } if (taskId == null) return; @@ -445,8 +447,7 @@ class TasksController { await _autoAssignTask(taskId: taskId, officeId: officeId ?? ''); } catch (e, st) { // keep creation successful but surface the error in logs for debugging - // ignore: avoid_print - print('autoAssignTask failed for task=$taskId: $e\n$st'); + debugPrint('autoAssignTask failed for task=$taskId: $e\n$st'); } unawaited(_notifyCreated(taskId: taskId, actorId: actorId)); @@ -467,8 +468,7 @@ class TasksController { 'tasks/$taskId/${DateTime.now().millisecondsSinceEpoch}.$extension'; try { // debug: show upload path - // ignore: avoid_print - print('uploadActionImage uploading to path: $path'); + debugPrint('uploadActionImage uploading to path: $path'); // perform the upload and capture whatever the SDK returns (it varies by platform) final dynamic res; if (kIsWeb) { @@ -486,8 +486,7 @@ class TasksController { await localFile.create(); await localFile.writeAsBytes(bytes); } catch (e) { - // ignore: avoid_print - print('uploadActionImage failed writing temp file: $e'); + debugPrint('uploadActionImage failed writing temp file: $e'); return null; } res = await _client.storage @@ -498,9 +497,9 @@ class TasksController { } catch (_) {} } - // debug: inspect the response object/type - // ignore: avoid_print - print('uploadActionImage response type=${res.runtimeType} value=$res'); + debugPrint( + 'uploadActionImage response type=${res.runtimeType} value=$res', + ); // Some SDK methods return a simple String (path) on success, others // return a StorageResponse with an error field. Avoid calling .error on a @@ -509,18 +508,15 @@ class TasksController { // treat as success } else if (res is Map && res['error'] != null) { // older versions might return a plain map - // ignore: avoid_print - print('uploadActionImage upload error: ${res['error']}'); + debugPrint('uploadActionImage upload error: ${res['error']}'); return null; } else if (res != null && res.error != null) { // StorageResponse case - // ignore: avoid_print - print('uploadActionImage upload error: ${res.error}'); + debugPrint('uploadActionImage upload error: ${res.error}'); return null; } } catch (e) { - // ignore: avoid_print - print('uploadActionImage failed upload: $e'); + debugPrint('uploadActionImage failed upload: $e'); return null; } try { @@ -528,8 +524,7 @@ class TasksController { .from(_actionImageBucket) .getPublicUrl(path); // debug: log full response - // ignore: avoid_print - print('uploadActionImage getPublicUrl response: $urlRes'); + debugPrint('uploadActionImage getPublicUrl response: $urlRes'); String? url; if (urlRes is String) { @@ -554,8 +549,7 @@ class TasksController { return '$supabaseUrl/storage/v1/object/public/$_actionImageBucket/$path' .trim(); } catch (e) { - // ignore: avoid_print - print('uploadActionImage getPublicUrl error: $e'); + debugPrint('uploadActionImage getPublicUrl error: $e'); return null; } } @@ -581,6 +575,90 @@ class TasksController { ) .toList(); await _client.from('notifications').insert(rows); + // Send FCM pushes with meaningful text + try { + // resolve actor display name if possible + String actorName = 'Someone'; + if (actorId != null && actorId.isNotEmpty) { + try { + final p = await _client + .from('profiles') + .select('full_name,display_name,name') + .eq('id', actorId) + .maybeSingle(); + if (p != null) { + if (p['full_name'] != null) { + actorName = p['full_name'].toString(); + } else if (p['display_name'] != null) { + actorName = p['display_name'].toString(); + } else if (p['name'] != null) { + actorName = p['name'].toString(); + } + } + } catch (_) {} + } + + // fetch task_number and office_id for nicer message and deep-linking + String? taskNumber; + String? officeId; + try { + final t = await _client + .from('tasks') + .select('task_number, office_id') + .eq('id', taskId) + .maybeSingle(); + if (t != null) { + if (t['task_number'] != null) + taskNumber = t['task_number'].toString(); + if (t['office_id'] != null) officeId = t['office_id'].toString(); + } + } catch (_) {} + + // resolve office name when available + String? officeName; + if (officeId != null && officeId.isNotEmpty) { + try { + final o = await _client + .from('offices') + .select('name') + .eq('id', officeId) + .maybeSingle(); + if (o != null && o['name'] != null) + officeName = o['name'].toString(); + } catch (_) {} + } + + final title = officeName != null + ? '$actorName created a new task in $officeName' + : '$actorName created a new task'; + final body = taskNumber != null + ? (officeName != null + ? '$actorName created task #$taskNumber in $officeName' + : '$actorName created task #$taskNumber') + : (officeName != null + ? '$actorName created a new task in $officeName' + : '$actorName created a new task'); + + final dataPayload = { + 'type': 'created', + if (taskNumber != null) 'task_number': taskNumber, + if (officeId != null) 'office_id': officeId, + if (officeName != null) 'office_name': officeName, + }; + + await _client.functions.invoke( + 'send_fcm', + body: { + 'user_ids': recipients, + 'title': title, + 'body': body, + 'data': dataPayload, + }, + ); + } catch (e) { + // non-fatal: push failure should not break flow + debugPrint('notifyCreated push error: $e'); + } } catch (_) { return; } @@ -919,11 +997,63 @@ class TasksController { 'task_id': taskId, 'type': 'assignment', }); + // send push for auto-assignment + try { + final actorName = 'Dispatcher'; + final title = '$actorName assigned you a task'; + final body = '$actorName assigned you a task'; + // fetch task_number and office for nicer deep-linking when available + String? taskNumber; + String? officeId; + try { + final t = await _client + .from('tasks') + .select('task_number, office_id') + .eq('id', taskId) + .maybeSingle(); + if (t != null) { + if (t['task_number'] != null) + taskNumber = t['task_number'].toString(); + if (t['office_id'] != null) officeId = t['office_id'].toString(); + } + } catch (_) {} + + String? officeName; + if (officeId != null && officeId.isNotEmpty) { + try { + final o = await _client + .from('offices') + .select('name') + .eq('id', officeId) + .maybeSingle(); + if (o != null && o['name'] != null) + officeName = o['name'].toString(); + } catch (_) {} + } + + final dataPayload = { + 'type': 'assignment', + if (taskNumber != null) 'task_number': taskNumber, + if (officeId != null) 'office_id': officeId, + if (officeName != null) 'office_name': officeName, + }; + + await _client.functions.invoke( + 'send_fcm', + body: { + 'user_ids': [chosen.userId], + 'title': title, + 'body': body, + 'data': dataPayload, + }, + ); + } catch (e) { + debugPrint('autoAssign push error: $e'); + } } catch (_) {} } catch (e, st) { // Log error for visibility and record a failed auto-assign activity - // ignore: avoid_print - print('autoAssignTask error for task=$taskId: $e\n$st'); + debugPrint('autoAssignTask error for task=$taskId: $e\n$st'); try { await _insertActivityRows(_client, { 'task_id': taskId, @@ -1060,6 +1190,85 @@ class TaskAssignmentsController { ) .toList(); await _client.from('notifications').insert(rows); + // send FCM pushes for explicit assignments + try { + String actorName = 'Someone'; + if (actorId != null && actorId.isNotEmpty) { + try { + final p = await _client + .from('profiles') + .select('full_name,display_name,name') + .eq('id', actorId) + .maybeSingle(); + if (p != null) { + if (p['full_name'] != null) + actorName = p['full_name'].toString(); + else if (p['display_name'] != null) + actorName = p['display_name'].toString(); + else if (p['name'] != null) + actorName = p['name'].toString(); + } + } catch (_) {} + } + + final title = '$actorName assigned you to a task'; + final body = '$actorName has assigned you to a task'; + + // fetch task_number and office for nicer deep-linking when available + String? taskNumber; + String? officeId; + try { + final t = await _client + .from('tasks') + .select('task_number, office_id') + .eq('id', taskId) + .maybeSingle(); + if (t != null) { + if (t['task_number'] != null) + taskNumber = t['task_number'].toString(); + if (t['office_id'] != null) officeId = t['office_id'].toString(); + } + } catch (_) {} + + String? officeName; + if (officeId != null && officeId.isNotEmpty) { + try { + final o = await _client + .from('offices') + .select('name') + .eq('id', officeId) + .maybeSingle(); + if (o != null && o['name'] != null) + officeName = o['name'].toString(); + } catch (_) {} + } + + final dataPayload = { + 'type': 'assignment', + if (taskNumber != null) 'task_number': taskNumber, + if (ticketId != null) 'ticket_id': ticketId, + if (officeId != null) 'office_id': officeId, + if (officeName != null) 'office_name': officeName, + }; + + // include office name in title/body when possible + final displayTitle = officeName != null + ? '$title in $officeName' + : title; + final displayBody = officeName != null ? '$body in $officeName' : body; + + await _client.functions.invoke( + 'send_fcm', + body: { + 'user_ids': userIds, + 'title': displayTitle, + 'body': displayBody, + 'data': dataPayload, + }, + ); + } catch (e) { + debugPrint('notifyAssigned push error: $e'); + } } catch (_) { return; } diff --git a/lib/providers/tickets_provider.dart b/lib/providers/tickets_provider.dart index b8f3957a..dfc45254 100644 --- a/lib/providers/tickets_provider.dart +++ b/lib/providers/tickets_provider.dart @@ -268,6 +268,62 @@ class TicketsController { ) .toList(); await _client.from('notifications').insert(rows); + // Send FCM pushes for ticket creation + try { + String actorName = 'Someone'; + if (actorId != null && actorId.isNotEmpty) { + try { + final p = await _client + .from('profiles') + .select('full_name,display_name,name') + .eq('id', actorId) + .maybeSingle(); + if (p != null) { + if (p['full_name'] != null) { + actorName = p['full_name'].toString(); + } else if (p['display_name'] != null) { + actorName = p['display_name'].toString(); + } else if (p['name'] != null) { + actorName = p['name'].toString(); + } + } + } catch (_) {} + } + + String? ticketNumber; + try { + final t = await _client + .from('tickets') + .select('ticket_number') + .eq('id', ticketId) + .maybeSingle(); + if (t != null && t['ticket_number'] != null) { + ticketNumber = t['ticket_number'].toString(); + } + } catch (_) {} + + final title = '$actorName created a new ticket'; + final body = ticketNumber != null + ? '$actorName created ticket #$ticketNumber' + : '$actorName created a new ticket'; + + await _client.functions.invoke( + 'send_fcm', + body: { + 'user_ids': recipients, + 'title': title, + 'body': body, + 'data': { + 'ticket_id': ticketId, + if (ticketNumber != null) 'ticket_number': ticketNumber, + 'type': 'created', + }, + }, + ); + } catch (e) { + // non-fatal + debugPrint('ticket notifyCreated push error: $e'); + } } catch (_) { return; } diff --git a/supabase/functions/send_fcm/index.ts b/supabase/functions/send_fcm/index.ts index a7e2bc84..b1d8b404 100644 --- a/supabase/functions/send_fcm/index.ts +++ b/supabase/functions/send_fcm/index.ts @@ -1,22 +1,19 @@ import { createClient } from 'npm:@supabase/supabase-js@2' import { JWT } from 'npm:google-auth-library@9' +import serviceAccount from './service-account.json' with { type: 'json' } -// CRITICAL: We MUST use the static file import to prevent the 546 CPU crash -import serviceAccount from '../service-account.json' with { type: 'json' } - -interface Notification { - id: string - user_id: string - type: string - ticket_id: string | null - task_id: string | null +// 1. MUST DEFINE CORS HEADERS FOR CLIENT INVOCATION +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', } -interface WebhookPayload { - type: 'INSERT' - table: string - record: Notification - schema: 'public' +interface ClientPayload { + user_ids: string[] + tokens?: string[] + title: string + body: string + data: Record } const supabase = createClient( @@ -25,37 +22,54 @@ const supabase = createClient( ) Deno.serve(async (req) => { - try { - const payload: WebhookPayload = await req.json() + // 2. INTERCEPT CORS PREFLIGHT REQUEST + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }) + } - // 1. Get all tokens for this user + try { + const payload: ClientPayload = await req.json() + + // Ensure we have users to send to + if (!payload.user_ids || payload.user_ids.length === 0) { + return new Response('No user_ids provided', { + status: 400, + headers: corsHeaders + }) + } + + // Optional Idempotency (if you pass notification_id inside the data payload) + if (payload.data && payload.data.notification_id) { + const { data: markData, error: markErr } = await supabase + .rpc('try_mark_notification_pushed', { p_notification_id: payload.data.notification_id }) + + if (markData === false) { + console.log('Notification already pushed, skipping:', payload.data.notification_id) + return new Response('Already pushed', { status: 200, headers: corsHeaders }) + } + } + + // 3. Get all tokens for these users using the .in() filter const { data: tokenData, error } = await supabase .from('fcm_tokens') .select('token') - .eq('user_id', payload.record.user_id) + .in('user_id', payload.user_ids) if (error || !tokenData || tokenData.length === 0) { - console.log('No active FCM tokens found for user:', payload.record.user_id) - return new Response('No tokens', { status: 200 }) + console.log('No active FCM tokens found for users:', payload.user_ids) + return new Response('No tokens', { status: 200, headers: corsHeaders }) } - // 2. Auth with Google using the statically loaded JSON + // Auth with Google const accessToken = await getAccessToken({ clientEmail: serviceAccount.client_email, privateKey: serviceAccount.private_key, }) - // 3. Build the notification text - const notificationTitle = `New ${payload.record.type}` - let notificationBody = 'You have a new update in TasQ.' - if (payload.record.ticket_id) { - notificationBody = 'You have a new update on your ticket.' - } else if (payload.record.task_id) { - notificationBody = 'You have a new update on your task.' - } - - // 4. Send to all devices concurrently - const sendPromises = tokenData.map(async (row) => { + // 4. Dedupe tokens and send concurrently + const uniqueTokens = Array.from(new Set(tokenData.map((r: any) => r.token))); + + const sendPromises = uniqueTokens.map(async (token) => { const res = await fetch( `https://fcm.googleapis.com/v1/projects/${serviceAccount.project_id}/messages:send`, { @@ -66,21 +80,16 @@ Deno.serve(async (req) => { }, body: JSON.stringify({ message: { - token: row.token, - // ❌ REMOVED the top-level 'notification' block entirely! + token, + // Send Data-Only payload so Flutter handles the sound/UI data: { - title: notificationTitle, // ✅ Moved title here - body: notificationBody, // ✅ Moved body here - notification_id: payload.record.id, - type: payload.record.type, - ticket_id: payload.record.ticket_id || '', - task_id: payload.record.task_id || '', + title: payload.title, + body: payload.body, + ...payload.data // Merges ticket_id, task_id, type, etc. }, - // Android priority (keep this to wake the device) android: { priority: 'high', }, - // iOS must STILL use the apns block for background processing apns: { payload: { aps: { @@ -94,29 +103,56 @@ Deno.serve(async (req) => { } ) - const resData = await res.json() - - // 5. Automatic Cleanup: If Firebase says the token is dead, delete it from the DB - if (!res.ok && resData.error?.details?.[0]?.errorCode === 'UNREGISTERED') { - console.log(`Dead token detected. Removing from DB: ${row.token}`) - await supabase.from('fcm_tokens').delete().eq('token', row.token) + let resData: any = null + try { + const text = await res.text() + if (text && text.length > 0) { + try { + resData = JSON.parse(text) + } catch (parseErr) { + resData = { rawText: text } + } + } + } catch (readErr) { + console.warn('Failed to read FCM response body', { token, err: readErr }) } - return { token: row.token, status: res.status, response: resData } + // Cleanup dead tokens + const isUnregistered = !!( + resData && + ( + resData.error?.details?.[0]?.errorCode === 'UNREGISTERED' || + (typeof resData.error?.message === 'string' && resData.error.message.toLowerCase().includes('unregistered')) || + (typeof resData.rawText === 'string' && resData.rawText.toLowerCase().includes('unregistered')) + ) + ) + + if (isUnregistered) { + console.log(`Dead token detected. Removing from DB: ${token}`) + await supabase.from('fcm_tokens').delete().eq('token', token) + } + + return { token, status: res.status, response: resData } }) const results = await Promise.all(sendPromises) + // 5. MUST ATTACH CORS HEADERS TO SUCCESS RESPONSE return new Response(JSON.stringify(results), { - headers: { 'Content-Type': 'application/json' }, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }) + } catch (err) { console.error('FCM Error:', err) - return new Response(JSON.stringify({ error: String(err) }), { status: 500 }) + // MUST ATTACH CORS HEADERS TO ERROR RESPONSE + return new Response(JSON.stringify({ error: String(err) }), { + status: 500, + headers: corsHeaders + }) } }) -// JWT helper +// JWT helper (unchanged) const getAccessToken = ({ clientEmail, privateKey, diff --git a/supabase/migrations/20260228120000_enforce_fcm_device_id_not_null.sql b/supabase/migrations/20260228120000_enforce_fcm_device_id_not_null.sql new file mode 100644 index 00000000..5fe31958 --- /dev/null +++ b/supabase/migrations/20260228120000_enforce_fcm_device_id_not_null.sql @@ -0,0 +1,24 @@ +-- 2026-02-28: cleanup duplicates and enforce device_id NOT NULL +-- 1) Deduplicate rows keeping the newest by created_at +WITH ranked AS ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY token, user_id ORDER BY created_at DESC) rn + FROM public.fcm_tokens +) +DELETE FROM public.fcm_tokens +WHERE id IN (SELECT id FROM ranked WHERE rn > 1); + +-- 2) Ensure device_id is populated for any legacy rows +UPDATE public.fcm_tokens +SET device_id = gen_random_uuid()::text +WHERE device_id IS NULL; + +-- 3) Enforce NOT NULL on device_id +ALTER TABLE public.fcm_tokens +ALTER COLUMN device_id SET NOT NULL; + +-- 4) Ensure unique index on (user_id, device_id) exists +CREATE UNIQUE INDEX IF NOT EXISTS fcm_tokens_user_device_idx + ON public.fcm_tokens(user_id, device_id); + +-- 5) (Optional) keep a normal index on user_id for queries +CREATE INDEX IF NOT EXISTS fcm_tokens_user_idx ON public.fcm_tokens(user_id); diff --git a/supabase/migrations/20260228150000_add_notification_pushes.sql b/supabase/migrations/20260228150000_add_notification_pushes.sql new file mode 100644 index 00000000..c23f5b31 --- /dev/null +++ b/supabase/migrations/20260228150000_add_notification_pushes.sql @@ -0,0 +1,20 @@ +-- Create a table to record which notifications have been pushed by the edge function +CREATE TABLE IF NOT EXISTS public.notification_pushes ( + notification_id uuid PRIMARY KEY, + pushed_at timestamptz NOT NULL DEFAULT now() +); + +-- Helper function: try to mark a notification as pushed. Returns true if the +-- insert succeeded (i.e. this notification was not previously pushed), false +-- if it already existed. +CREATE OR REPLACE FUNCTION public.try_mark_notification_pushed(p_notification_id uuid) +RETURNS boolean LANGUAGE plpgsql AS $$ +BEGIN + INSERT INTO public.notification_pushes(notification_id) VALUES (p_notification_id) + ON CONFLICT DO NOTHING; + RETURN FOUND; -- true if insert happened, false if ON CONFLICT prevented insert +END; +$$; + +-- index for quick lookup by pushed_at if needed +CREATE INDEX IF NOT EXISTS idx_notification_pushes_pushed_at ON public.notification_pushes(pushed_at); diff --git a/supabase/migrations/20260228153000_drop_notifications_trigger.sql b/supabase/migrations/20260228153000_drop_notifications_trigger.sql new file mode 100644 index 00000000..e66e4032 --- /dev/null +++ b/supabase/migrations/20260228153000_drop_notifications_trigger.sql @@ -0,0 +1,16 @@ +-- Migration: Drop notifications trigger that posts to the send_fcm edge function +-- Reason: moving push invocation to client-side; remove server trigger to avoid duplicate sends +BEGIN; + +-- Remove the trigger that calls the edge function on insert into public.notifications +DROP TRIGGER IF EXISTS notifications_send_fcm_trigger ON public.notifications; + +-- Remove the trigger function as well (if present) +DROP FUNCTION IF EXISTS notifications_send_fcm_trigger() CASCADE; + +COMMIT; + +-- NOTE: After deploying this migration, the application will rely on the +-- client-side push path. Ensure client builds are released and rollout is +-- coordinated before applying this migration in production to avoid missing +-- pushes for any clients that still expect server-side delivery.