Move push notification to client instead of db trigger
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user