Move push notification to client instead of db trigger

This commit is contained in:
2026-02-28 16:19:08 +08:00
parent dab43a7f30
commit 0a8e388757
8 changed files with 698 additions and 226 deletions
+238 -127
View File
@@ -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<String?>? _fcmTokenRefreshSub;
Map<String, String> _formatNotificationFromData(Map<String, dynamic> 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<void> _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<void> 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<void> 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<String, dynamic> dataForFormatting = Map<String, dynamic>.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<void> 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<String?>? _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<String, dynamic> 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<String, dynamic> 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();
+22 -22
View File
@@ -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<String, dynamic>? 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 = <String, dynamic>{
'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');
+234 -25
View File
@@ -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 = <String, dynamic>{
'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 = <String, dynamic>{
'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 = <String, dynamic>{
'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;
}
+56
View File
@@ -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;
}