Move push notification to client instead of db trigger
This commit is contained in:
parent
dab43a7f30
commit
0a8e388757
365
lib/main.dart
365
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<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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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.
|
||||
Loading…
Reference in New Issue
Block a user