diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 63cbf522..e482414b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,5 +1,8 @@ plugins { id("com.android.application") + // START: FlutterFire Configuration + id("com.google.gms.google-services") + // END: FlutterFire Configuration id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100644 index 00000000..2ec8a29b --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "173359574734", + "project_id": "tasq-17fb3", + "storage_bucket": "tasq-17fb3.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:173359574734:android:28f9a6792ea579ad2baa9f", + "android_client_info": { + "package_name": "com.example.tasq" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyDt0ZYxfjXXxF4PS8NKbzOHFSHJC2LFvU4" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index ca7fe065..174f4082 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -20,6 +20,9 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.11.1" apply false + // START: FlutterFire Configuration + id("com.google.gms.google-services") version("4.3.15") apply false + // END: FlutterFire Configuration id("org.jetbrains.kotlin.android") version "2.2.20" apply false } diff --git a/firebase.json b/firebase.json new file mode 100644 index 00000000..247c8fb0 --- /dev/null +++ b/firebase.json @@ -0,0 +1 @@ +{"flutter":{"platforms":{"android":{"default":{"projectId":"tasq-17fb3","appId":"1:173359574734:android:28f9a6792ea579ad2baa9f","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"tasq-17fb3","configurations":{"android":"1:173359574734:android:28f9a6792ea579ad2baa9f","ios":"1:173359574734:ios:2acd406a087240172baa9f","macos":"1:173359574734:ios:2acd406a087240172baa9f","web":"1:173359574734:web:f894a6b43a443e902baa9f","windows":"1:173359574734:web:c7603df3290c4a832baa9f"}}}}}} \ No newline at end of file diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart new file mode 100644 index 00000000..2a9834c7 --- /dev/null +++ b/lib/firebase_options.dart @@ -0,0 +1,86 @@ +// File generated by FlutterFire CLI. +// ignore_for_file: type=lint +import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; + +/// Default [FirebaseOptions] for use with your Firebase apps. +/// +/// Example: +/// ```dart +/// import 'firebase_options.dart'; +/// // ... +/// await Firebase.initializeApp( +/// options: DefaultFirebaseOptions.currentPlatform, +/// ); +/// ``` +class DefaultFirebaseOptions { + static FirebaseOptions get currentPlatform { + if (kIsWeb) { + return web; + } + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return android; + case TargetPlatform.iOS: + return ios; + case TargetPlatform.macOS: + return macos; + case TargetPlatform.windows: + return windows; + case TargetPlatform.linux: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for linux - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + default: + throw UnsupportedError( + 'DefaultFirebaseOptions are not supported for this platform.', + ); + } + } + + static const FirebaseOptions web = FirebaseOptions( + apiKey: 'AIzaSyBKGSaHYiqpZvbEgsvJJY45soiIkV6MV3M', + appId: '1:173359574734:web:f894a6b43a443e902baa9f', + messagingSenderId: '173359574734', + projectId: 'tasq-17fb3', + authDomain: 'tasq-17fb3.firebaseapp.com', + storageBucket: 'tasq-17fb3.firebasestorage.app', + ); + + static const FirebaseOptions android = FirebaseOptions( + apiKey: 'AIzaSyDt0ZYxfjXXxF4PS8NKbzOHFSHJC2LFvU4', + appId: '1:173359574734:android:28f9a6792ea579ad2baa9f', + messagingSenderId: '173359574734', + projectId: 'tasq-17fb3', + storageBucket: 'tasq-17fb3.firebasestorage.app', + ); + + static const FirebaseOptions ios = FirebaseOptions( + apiKey: 'AIzaSyCeOKj8Q_E45Vn2XrLmQekTPGaG3T-unS4', + appId: '1:173359574734:ios:2acd406a087240172baa9f', + messagingSenderId: '173359574734', + projectId: 'tasq-17fb3', + storageBucket: 'tasq-17fb3.firebasestorage.app', + iosBundleId: 'com.example.tasq', + ); + + static const FirebaseOptions macos = FirebaseOptions( + apiKey: 'AIzaSyCeOKj8Q_E45Vn2XrLmQekTPGaG3T-unS4', + appId: '1:173359574734:ios:2acd406a087240172baa9f', + messagingSenderId: '173359574734', + projectId: 'tasq-17fb3', + storageBucket: 'tasq-17fb3.firebasestorage.app', + iosBundleId: 'com.example.tasq', + ); + + static const FirebaseOptions windows = FirebaseOptions( + apiKey: 'AIzaSyBKGSaHYiqpZvbEgsvJJY45soiIkV6MV3M', + appId: '1:173359574734:web:c7603df3290c4a832baa9f', + messagingSenderId: '173359574734', + projectId: 'tasq-17fb3', + authDomain: 'tasq-17fb3.firebaseapp.com', + storageBucket: 'tasq-17fb3.firebasestorage.app', + ); +} diff --git a/lib/main.dart b/lib/main.dart index b1aa92d1..6c6b601e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,18 +1,45 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:audioplayers/audioplayers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; 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'; import 'app.dart'; + import 'providers/notifications_provider.dart'; import 'utils/app_time.dart'; import 'utils/notification_permission.dart'; import 'services/notification_service.dart'; +import 'services/notification_bridge.dart'; + +/// Handle messages received while the app is terminated or in background. +Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { + // initialize plugin in background isolate + await NotificationService.initialize(); + final notification = message.notification; + if (notification != null) { + NotificationService.show( + id: DateTime.now().millisecondsSinceEpoch ~/ 1000, + title: notification.title ?? 'Notification', + body: notification.body ?? '', + payload: message.data['payload'], + ); + } +} Future main() async { WidgetsFlutterBinding.ensureInitialized(); + // initialize Firebase before anything that uses messaging + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); + await dotenv.load(fileName: '.env'); AppTime.initialize(location: 'Asia/Manila'); @@ -27,6 +54,29 @@ Future main() async { await Supabase.initialize(url: supabaseUrl, anonKey: supabaseAnonKey); + // ensure token saved right away if already signed in + final supaClient = Supabase.instance.client; + final initialToken = await FirebaseMessaging.instance.getToken(); + if (initialToken != null && supaClient.auth.currentUser != null) { + debugPrint('initial FCM token for signed-in user: $initialToken'); + final ctrl = NotificationsController(supaClient); + await ctrl.registerFcmToken(initialToken); + } + + // listen for auth changes to register/unregister token accordingly + supaClient.auth.onAuthStateChange.listen((data) async { + final event = data.event; + final token = await FirebaseMessaging.instance.getToken(); + debugPrint('auth state change $event, token=$token'); + if (token == null) return; + final ctrl = NotificationsController(supaClient); + if (event == AuthChangeEvent.signedIn) { + await ctrl.registerFcmToken(token); + } else if (event == AuthChangeEvent.signedOut) { + await ctrl.unregisterFcmToken(token); + } + }); + // on Android 13+ we must request POST_NOTIFICATIONS at runtime; without it // notifications are automatically denied and cannot be re‑enabled from the // system settings. The helper uses `permission_handler`. @@ -37,6 +87,20 @@ Future main() async { // debugPrint('notification permission not granted'); } + // request FCM permission (iOS/Android13+) and handle foreground messages + await FirebaseMessaging.instance.requestPermission(); + FirebaseMessaging.onMessage.listen((RemoteMessage message) { + final notification = message.notification; + if (notification != null) { + NotificationService.show( + id: DateTime.now().millisecondsSinceEpoch ~/ 1000, + title: notification.title ?? 'Notification', + body: notification.body ?? '', + payload: message.data['payload'], + ); + } + }); + // initialize the local notifications plugin so we can post alerts later await NotificationService.initialize( onDidReceiveNotificationResponse: (response) { @@ -50,16 +114,23 @@ Future main() async { }, ); + // global navigator key used for snackbars/navigation from notification + final navigatorKey = GlobalKey(); + runApp( ProviderScope( observers: [NotificationSoundObserver()], - child: const TasqApp(), + child: NotificationBridge( + navigatorKey: navigatorKey, + child: const TasqApp(), + ), ), ); } class NotificationSoundObserver extends ProviderObserver { static final AudioPlayer _player = AudioPlayer(); + StreamSubscription? _tokenSub; @override void didUpdateProvider( @@ -68,12 +139,12 @@ class NotificationSoundObserver extends ProviderObserver { Object? newValue, 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) { _player.play(AssetSource('tasq_notification.wav')); - // also post a system notification so the user sees it outside the app NotificationService.show( id: DateTime.now().millisecondsSinceEpoch ~/ 1000, title: 'New notifications', @@ -81,6 +152,35 @@ class NotificationSoundObserver extends ProviderObserver { ); } } + + // 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 + FirebaseMessaging.instance.getToken().then((token) { + if (token != null) { + debugPrint('profile observer registering token: $token'); + controller.registerFcmToken(token); + } + }); + _tokenSub = FirebaseMessaging.instance.onTokenRefresh.listen((token) { + debugPrint('token refreshed: $token'); + controller.registerFcmToken(token); + }); + } else { + // signed out: unregister whatever token we currently have + _tokenSub?.cancel(); + FirebaseMessaging.instance.getToken().then((token) { + if (token != null) { + debugPrint('profile observer unregistering token: $token'); + controller.unregisterFcmToken(token); + } + }); + } + } } } diff --git a/lib/providers/notifications_provider.dart b/lib/providers/notifications_provider.dart index 756e2ba8..c0c30492 100644 --- a/lib/providers/notifications_provider.dart +++ b/lib/providers/notifications_provider.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -40,6 +41,72 @@ class NotificationsController { final SupabaseClient _client; + /// Internal helper that inserts notification rows and sends pushes if + /// [targetUserIds] is provided. + /// Internal helper that inserts notification rows and optionally sends + /// FCM pushes. Callers should use [createNotification] instead. + Future _createAndPush( + List> rows, { + List? targetUserIds, + String? pushTitle, + String? pushBody, + Map? pushData, + }) async { + if (rows.isEmpty) return; + debugPrint( + 'notifications_provider: inserting ${rows.length} rows; pushTitle=$pushTitle', + ); + 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 (targetUserIds == null || targetUserIds.isEmpty) return; + + debugPrint( + 'notification rows inserted; server trigger will perform pushes', + ); + } + + /// Create a typed notification in the database. This method handles + /// inserting the row(s); a PostgreSQL trigger will forward the new row to + /// the `send_fcm` edge function, so clients do **not** directly invoke it + /// (avoids CORS/auth problems on web). The [pushTitle]/[pushBody] values + /// are still stored for the trigger payload. + Future createNotification({ + required List userIds, + required String type, + required String actorId, + Map? fields, + String? pushTitle, + String? pushBody, + Map? pushData, + }) async { + debugPrint( + 'createNotification called type=$type users=${userIds.length} pushTitle=$pushTitle pushBody=$pushBody', + ); + if (userIds.isEmpty) return; + + final rows = userIds.map((userId) { + return { + 'user_id': userId, + 'actor_id': actorId, + 'type': type, + ...?fields, + }; + }).toList(); + + await _createAndPush( + rows, + targetUserIds: userIds, + pushTitle: pushTitle, + pushBody: pushBody, + pushData: pushData, + ); + } + + /// Convenience for mention-specific case; left for compatibility. Future createMentionNotifications({ required List userIds, required String actorId, @@ -47,24 +114,22 @@ class NotificationsController { String? ticketId, String? taskId, }) async { - if (userIds.isEmpty) return; - if ((ticketId == null || ticketId.isEmpty) && - (taskId == null || taskId.isEmpty)) { - return; - } - final rows = userIds - .map( - (userId) => { - 'user_id': userId, - 'actor_id': actorId, - 'ticket_id': ticketId, - 'task_id': taskId, - 'message_id': messageId, - 'type': 'mention', - }, - ) - .toList(); - await _client.from('notifications').insert(rows); + return createNotification( + userIds: userIds, + type: 'mention', + actorId: actorId, + fields: { + 'message_id': messageId, + if (ticketId != null) 'ticket_id': ticketId, + if (taskId != null) 'task_id': taskId, + }, + pushTitle: 'New mention', + pushBody: 'You were mentioned in a message', + pushData: { + if (ticketId != null) 'ticket_id': ticketId, + if (taskId != null) 'task_id': taskId, + }, + ); } Future markRead(String id) async { @@ -95,4 +160,85 @@ class NotificationsController { .eq('user_id', userId) .filter('read_at', 'is', null); } + + /// Store or update an FCM token for the current user. + Future registerFcmToken(String token) async { + final userId = _client.auth.currentUser?.id; + if (userId == null) return; + final res = await _client.from('fcm_tokens').insert({ + 'user_id': userId, + 'token': token, + }); + 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 + debugPrint('registerFcmToken error: ${dyn.error?.message ?? dyn.error}'); + } else { + debugPrint('registerFcmToken success for user=$userId token=$token'); + } + } + + /// Remove an FCM token (e.g. when the user logs out or uninstalls). + Future unregisterFcmToken(String token) async { + final userId = _client.auth.currentUser?.id; + if (userId == null) return; + final res = await _client + .from('fcm_tokens') + .delete() + .eq('user_id', userId) + .eq('token', token); + if (res == null) { + debugPrint( + 'unregisterFcmToken: null response for user=$userId token=$token', + ); + return; + } + final uDyn = res as dynamic; + if (uDyn.error != null) { + debugPrint( + 'unregisterFcmToken error: ${uDyn.error?.message ?? uDyn.error}', + ); + } + } + + /// Send a push message via the `send_fcm` edge function. + Future sendPush({ + List? tokens, + List? userIds, + required String title, + required String body, + Map? data, + }) async { + try { + if (tokens != null) { + debugPrint( + 'invoking send_fcm with ${tokens.length} tokens, title="$title"', + ); + } else if (userIds != null) { + debugPrint( + 'invoking send_fcm with userIds=${userIds.length}, title="$title"', + ); + } else { + debugPrint('sendPush called with neither tokens nor userIds'); + return; + } + final bodyPayload = { + if (tokens != null) 'tokens': tokens, + if (userIds != null) 'user_ids': userIds, + 'title': title, + '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 9f6c36a9..144f2bb8 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -685,6 +685,35 @@ class TasksController { await _client.from('tasks').update(payload).eq('id', taskId); } + /// Update editable task fields such as title, description, office or linked ticket. + Future updateTaskFields({ + required String taskId, + String? title, + String? description, + String? officeId, + String? ticketId, + }) async { + final payload = {}; + if (title != null) payload['title'] = title; + if (description != null) payload['description'] = description; + if (officeId != null) payload['office_id'] = officeId; + if (ticketId != null) payload['ticket_id'] = ticketId; + if (payload.isEmpty) return; + + await _client.from('tasks').update(payload).eq('id', taskId); + + // record an activity log for edit operations (best-effort) + try { + final actorId = _client.auth.currentUser?.id; + await _insertActivityRows(_client, { + 'task_id': taskId, + 'actor_id': actorId, + 'action_type': 'updated', + 'meta': payload, + }); + } catch (_) {} + } + // Auto-assignment logic executed once on creation. Future _autoAssignTask({ required String taskId, diff --git a/lib/providers/tickets_provider.dart b/lib/providers/tickets_provider.dart index 3e4dcbf5..b8f3957a 100644 --- a/lib/providers/tickets_provider.dart +++ b/lib/providers/tickets_provider.dart @@ -370,6 +370,32 @@ class TicketsController { } } } + + /// Update editable ticket fields such as subject, description, and office. + Future updateTicket({ + required String ticketId, + String? subject, + String? description, + String? officeId, + }) async { + final payload = {}; + if (subject != null) payload['subject'] = subject; + if (description != null) payload['description'] = description; + if (officeId != null) payload['office_id'] = officeId; + if (payload.isEmpty) return; + + await _client.from('tickets').update(payload).eq('id', ticketId); + + // record an activity row for edit operations (best-effort) + try { + final actorId = _client.auth.currentUser?.id; + await _client.from('ticket_messages').insert({ + 'ticket_id': ticketId, + 'sender_id': actorId, + 'content': 'Ticket updated', + }); + } catch (_) {} + } } class OfficesController { diff --git a/lib/screens/auth/signup_screen.dart b/lib/screens/auth/signup_screen.dart index ad343915..c623834d 100644 --- a/lib/screens/auth/signup_screen.dart +++ b/lib/screens/auth/signup_screen.dart @@ -212,22 +212,36 @@ class _SignUpScreenState extends ConsumerState { if (_selectedOfficeIds.isEmpty) const Text('No office selected.') else - Wrap( - spacing: 8, - runSpacing: 8, - children: _selectedOfficeIds.map((id) { - final name = officeNameById[id] ?? id; - return Chip( - label: Text(name), - onDeleted: _isLoading - ? null - : () { - setState( - () => _selectedOfficeIds.remove(id), - ); - }, + Builder( + builder: (context) { + final sortedIds = + List.from(_selectedOfficeIds)..sort( + (a, b) => (officeNameById[a] ?? a) + .toLowerCase() + .compareTo( + (officeNameById[b] ?? b) + .toLowerCase(), + ), + ); + return Wrap( + spacing: 8, + runSpacing: 8, + children: sortedIds.map((id) { + final name = officeNameById[id] ?? id; + return Chip( + label: Text(name), + onDeleted: _isLoading + ? null + : () { + setState( + () => + _selectedOfficeIds.remove(id), + ); + }, + ); + }).toList(), ); - }).toList(), + }, ), ], ); diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index 9c81d7a3..f84cd6a9 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -8,6 +8,7 @@ import '../../models/task_assignment.dart'; import '../../models/task_activity_log.dart'; import '../../models/ticket.dart'; import '../../models/ticket_message.dart'; +import '../../models/office.dart'; import '../../providers/notifications_provider.dart'; import 'dart:async'; import 'dart:convert'; @@ -22,6 +23,7 @@ import '../../providers/tasks_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../providers/typing_provider.dart'; import '../../utils/app_time.dart'; +import '../../utils/snackbar.dart'; import '../../widgets/app_breakpoints.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; @@ -29,7 +31,6 @@ import '../../widgets/status_pill.dart'; import '../../theme/app_surfaces.dart'; import '../../widgets/task_assignment_section.dart'; import '../../widgets/typing_dots.dart'; -import '../../utils/snackbar.dart'; // Simple image embed builder to support data-URI and network images class _ImageEmbedBuilder extends quill.EmbedBuilder { @@ -315,16 +316,41 @@ class _TaskDetailScreenState extends ConsumerState final detailsContent = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Align( - alignment: Alignment.center, - child: Text( - task.title.isNotEmpty - ? task.title - : 'Task ${task.taskNumber ?? task.id}', - textAlign: TextAlign.center, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), + Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + task.title.isNotEmpty + ? task.title + : 'Task ${task.taskNumber ?? task.id}', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 8), + Builder( + builder: (ctx) { + final profile = profileAsync.maybeWhen( + data: (p) => p, + orElse: () => null, + ); + final canEdit = + profile != null && + (profile.role == 'admin' || + profile.role == 'dispatcher' || + profile.role == 'it_staff' || + profile.id == task.creatorId); + if (!canEdit) return const SizedBox.shrink(); + return IconButton( + tooltip: 'Edit task', + onPressed: () => _showEditTaskDialog(ctx, ref, task), + icon: const Icon(Icons.edit), + ); + }, + ), + ], ), ), const SizedBox(height: 6), @@ -2143,31 +2169,15 @@ class _TaskDetailScreenState extends ConsumerState : assignedForTask.last; DateTime? firstMessageByAssignee; - if (latestAssignment != null) { - messagesAsync.when( - data: (messages) { - final byAssignee = - messages - .where((m) => m.senderId == latestAssignment.userId) - .where((m) => m.createdAt.isAfter(latestAssignment.createdAt)) - .toList() - ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); - if (byAssignee.isNotEmpty) { - firstMessageByAssignee = byAssignee.first.createdAt; - } - }, - loading: () {}, - error: (err, stack) {}, - ); - } - DateTime? startedByAssignee; - for (final l in logs) { - if (l.actionType == 'started' && latestAssignment != null) { - if (l.actorId == latestAssignment.userId && - l.createdAt.isAfter(latestAssignment.createdAt)) { - startedByAssignee = l.createdAt; - break; + if (latestAssignment != null) { + for (final l in logs) { + if (l.actionType == 'started') { + if (l.actorId == latestAssignment.userId && + l.createdAt.isAfter(latestAssignment.createdAt)) { + startedByAssignee = l.createdAt; + break; + } } } } @@ -2178,7 +2188,7 @@ class _TaskDetailScreenState extends ConsumerState final assignedAt = latestAssignment.createdAt; final candidates = []; if (firstMessageByAssignee != null) { - candidates.add(firstMessageByAssignee!); + candidates.add(firstMessageByAssignee); } if (startedByAssignee != null) { candidates.add(startedByAssignee); @@ -2693,6 +2703,100 @@ class _TaskDetailScreenState extends ConsumerState return '${names[0]}, ${names[1]} and others are typing...'; } + Future _showEditTaskDialog( + BuildContext context, + WidgetRef ref, + Task task, + ) async { + final officesAsync = ref.watch(officesOnceProvider); + final titleCtrl = TextEditingController(text: task.title); + final descCtrl = TextEditingController(text: task.description); + String? selectedOffice = task.officeId; + + await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + shape: AppSurfaces.of(context).dialogShape, + title: const Text('Edit Task'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: titleCtrl, + decoration: const InputDecoration(labelText: 'Title'), + ), + const SizedBox(height: 8), + TextField( + controller: descCtrl, + decoration: const InputDecoration(labelText: 'Description'), + maxLines: 4, + ), + const SizedBox(height: 8), + officesAsync.when( + data: (offices) { + final officesSorted = List.from(offices) + ..sort( + (a, b) => a.name.toLowerCase().compareTo( + b.name.toLowerCase(), + ), + ); + return DropdownButtonFormField( + initialValue: selectedOffice, + decoration: const InputDecoration(labelText: 'Office'), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Unassigned'), + ), + for (final o in officesSorted) + DropdownMenuItem(value: o.id, child: Text(o.name)), + ], + onChanged: (v) => selectedOffice = v, + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () async { + final outerContext = context; + final title = titleCtrl.text.trim(); + final desc = descCtrl.text.trim(); + try { + await ref + .read(tasksControllerProvider) + .updateTaskFields( + taskId: task.id, + title: title.isEmpty ? null : title, + description: desc.isEmpty ? null : desc, + officeId: selectedOffice, + ); + if (!mounted) return; + Navigator.of(outerContext).pop(); + showSuccessSnackBar(outerContext, 'Task updated'); + } catch (e) { + if (!mounted) return; + showErrorSnackBar(outerContext, 'Failed to update task: $e'); + } + }, + child: const Text('Save'), + ), + ], + ); + }, + ); + } + Task? _findTask(AsyncValue> tasksAsync, String taskId) { return tasksAsync.maybeWhen( data: (tasks) => tasks.where((task) => task.id == taskId).firstOrNull, diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index cc8c5048..5c7bc219 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -106,12 +106,17 @@ class _TasksListScreenState extends ConsumerState { return const Center(child: Text('No tasks yet.')); } final offices = officesAsync.valueOrNull ?? []; + final officesSorted = List.from(offices) + ..sort( + (a, b) => + a.name.toLowerCase().compareTo(b.name.toLowerCase()), + ); final officeOptions = >[ const DropdownMenuItem( value: null, child: Text('All offices'), ), - ...offices.map( + ...officesSorted.map( (office) => DropdownMenuItem( value: office.id, child: Text(office.name), @@ -461,7 +466,13 @@ class _TasksListScreenState extends ConsumerState { if (offices.isEmpty) { return const Text('No offices available.'); } - selectedOfficeId ??= offices.first.id; + final officesSorted = List.from(offices) + ..sort( + (a, b) => a.name.toLowerCase().compareTo( + b.name.toLowerCase(), + ), + ); + selectedOfficeId ??= officesSorted.first.id; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -470,7 +481,7 @@ class _TasksListScreenState extends ConsumerState { decoration: const InputDecoration( labelText: 'Office', ), - items: offices + items: officesSorted .map( (office) => DropdownMenuItem( value: office.id, diff --git a/lib/screens/teams/teams_screen.dart b/lib/screens/teams/teams_screen.dart index 16a96db8..bb012388 100644 --- a/lib/screens/teams/teams_screen.dart +++ b/lib/screens/teams/teams_screen.dart @@ -163,9 +163,13 @@ class _TeamsScreenState extends ConsumerState { TasQColumn( header: 'Offices', cellBuilder: (context, team) { - final officeNames = team.officeIds + final officeNamesList = team.officeIds .map((id) => officeById[id]?.name ?? id) - .join(', '); + .toList(); + officeNamesList.sort( + (a, b) => a.toLowerCase().compareTo(b.toLowerCase()), + ); + final officeNames = officeNamesList.join(', '); return Text(officeNames); }, ), @@ -182,9 +186,13 @@ class _TeamsScreenState extends ConsumerState { ], mobileTileBuilder: (context, team, actions) { final leader = profileById[team.leaderId]; - final officeNames = team.officeIds + final officeNamesList = team.officeIds .map((id) => officeById[id]?.name ?? id) - .join(', '); + .toList(); + officeNamesList.sort( + (a, b) => a.toLowerCase().compareTo(b.toLowerCase()), + ); + final officeNames = officeNamesList.join(', '); final members = team.members(teamMembers); final memberNames = members .map((id) => profileById[id]?.fullName ?? id) diff --git a/lib/screens/tickets/ticket_detail_screen.dart b/lib/screens/tickets/ticket_detail_screen.dart index 80e69440..bba4e567 100644 --- a/lib/screens/tickets/ticket_detail_screen.dart +++ b/lib/screens/tickets/ticket_detail_screen.dart @@ -14,6 +14,7 @@ import '../../providers/profile_provider.dart'; import '../../providers/tasks_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../providers/typing_provider.dart'; +import '../../utils/snackbar.dart'; import '../../widgets/app_breakpoints.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; @@ -95,14 +96,40 @@ class _TicketDetailScreenState extends ConsumerState { final detailsContent = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Align( - alignment: Alignment.center, - child: Text( - ticket.subject, - textAlign: TextAlign.center, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), + Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + ticket.subject, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 8), + Builder( + builder: (ctx) { + final profile = currentProfileAsync.maybeWhen( + data: (p) => p, + orElse: () => null, + ); + final canEdit = + profile != null && + (profile.role == 'admin' || + profile.role == 'dispatcher' || + profile.role == 'it_staff' || + profile.id == ticket.creatorId); + if (!canEdit) return const SizedBox.shrink(); + return IconButton( + tooltip: 'Edit ticket', + onPressed: () => + _showEditTicketDialog(ctx, ref, ticket), + icon: const Icon(Icons.edit), + ); + }, + ), + ], ), ), const SizedBox(height: 6), @@ -853,6 +880,103 @@ class _TicketDetailScreenState extends ConsumerState { ); } + Future _showEditTicketDialog( + BuildContext context, + WidgetRef ref, + Ticket ticket, + ) async { + final officesAsync = ref.watch(officesOnceProvider); + final subjectCtrl = TextEditingController(text: ticket.subject); + final descCtrl = TextEditingController(text: ticket.description); + String? selectedOffice = ticket.officeId; + + await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + shape: AppSurfaces.of(context).dialogShape, + title: const Text('Edit Ticket'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: subjectCtrl, + decoration: const InputDecoration(labelText: 'Subject'), + ), + const SizedBox(height: 8), + TextField( + controller: descCtrl, + decoration: const InputDecoration(labelText: 'Description'), + maxLines: 4, + ), + const SizedBox(height: 8), + officesAsync.when( + data: (offices) { + final officesSorted = List.from(offices) + ..sort( + (a, b) => a.name.toLowerCase().compareTo( + b.name.toLowerCase(), + ), + ); + return DropdownButtonFormField( + initialValue: selectedOffice, + decoration: const InputDecoration(labelText: 'Office'), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Unassigned'), + ), + for (final o in officesSorted) + DropdownMenuItem(value: o.id, child: Text(o.name)), + ], + onChanged: (v) => selectedOffice = v, + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () async { + final outerContext = context; + final subject = subjectCtrl.text.trim(); + final desc = descCtrl.text.trim(); + try { + await ref + .read(ticketsControllerProvider) + .updateTicket( + ticketId: ticket.id, + subject: subject.isEmpty ? null : subject, + description: desc.isEmpty ? null : desc, + officeId: selectedOffice, + ); + if (!mounted) return; + Navigator.of(outerContext).pop(); + showSuccessSnackBar(outerContext, 'Ticket updated'); + } catch (e) { + if (!mounted) return; + showErrorSnackBar( + outerContext, + 'Failed to update ticket: $e', + ); + } + }, + child: const Text('Save'), + ), + ], + ); + }, + ); + } + Widget _timelineRow(String label, DateTime? value) { return Padding( padding: const EdgeInsets.only(bottom: 8), diff --git a/lib/screens/tickets/tickets_list_screen.dart b/lib/screens/tickets/tickets_list_screen.dart index 4061214a..68866c97 100644 --- a/lib/screens/tickets/tickets_list_screen.dart +++ b/lib/screens/tickets/tickets_list_screen.dart @@ -70,12 +70,17 @@ class _TicketsListScreenState extends ConsumerState { }; final unreadByTicketId = _unreadByTicketId(notificationsAsync); final offices = officesAsync.valueOrNull ?? []; + final officesSorted = List.from(offices) + ..sort( + (a, b) => + a.name.toLowerCase().compareTo(b.name.toLowerCase()), + ); final officeOptions = >[ const DropdownMenuItem( value: null, child: Text('All offices'), ), - ...offices.map( + ...officesSorted.map( (office) => DropdownMenuItem( value: office.id, child: Text(office.name), @@ -356,11 +361,17 @@ class _TicketsListScreenState extends ConsumerState { if (offices.isEmpty) { return const Text('No offices assigned.'); } - selectedOffice ??= offices.first; + final officesSorted = List.from(offices) + ..sort( + (a, b) => a.name.toLowerCase().compareTo( + b.name.toLowerCase(), + ), + ); + selectedOffice ??= officesSorted.first; return DropdownButtonFormField( key: ValueKey(selectedOffice?.id), initialValue: selectedOffice, - items: offices + items: officesSorted .map( (office) => DropdownMenuItem( value: office, diff --git a/lib/services/notification_bridge.dart b/lib/services/notification_bridge.dart new file mode 100644 index 00000000..b33c0d12 --- /dev/null +++ b/lib/services/notification_bridge.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; + +import '../models/notification_item.dart'; +import '../providers/notifications_provider.dart'; + +/// Wraps the app and installs both a Supabase realtime listener and the +/// FCM handlers described in the frontend design. +/// +/// The navigator key is required so that snackbars and navigation can be +/// triggered from outside the widget tree (e.g. from realtime callbacks). +class NotificationBridge extends ConsumerStatefulWidget { + const NotificationBridge({ + required this.navigatorKey, + required this.child, + super.key, + }); + + final GlobalKey navigatorKey; + final Widget child; + + @override + ConsumerState createState() => _NotificationBridgeState(); +} + +class _NotificationBridgeState extends ConsumerState { + // store previous notifications to diff + List _prevList = []; + + @override + void initState() { + super.initState(); + _setupFcmHandlers(); + } + + @override + void dispose() { + super.dispose(); + } + + void _showBanner(String type, NotificationItem item) { + final ctx = widget.navigatorKey.currentState?.overlay?.context; + if (ctx == null) return; + + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar( + content: Text('New $type received!'), + action: SnackBarAction( + label: 'View', + onPressed: () => _navigateToNotification(item), + ), + ), + ); + } + + void _navigateToNotification(NotificationItem item) { + widget.navigatorKey.currentState?.pushNamed( + '/notification-detail', + arguments: item, + ); + } + + void _setupFcmHandlers() { + // ignore foreground messages; realtime websocket will surface them + FirebaseMessaging.onMessage.listen((_) {}); + + // handle taps when the app is backgrounded + FirebaseMessaging.onMessageOpenedApp.listen(_handleMessageTap); + + // handle a tap that launched the app from a terminated state + FirebaseMessaging.instance.getInitialMessage().then((msg) { + if (msg != null) _handleMessageTap(msg); + }); + } + + void _handleMessageTap(RemoteMessage message) { + final data = message.data.cast(); + final item = NotificationItem.fromMap(data); + _navigateToNotification(item); + } + + @override + Widget build(BuildContext context) { + // listen inside build; safe with ConsumerState + ref.listen>>(notificationsProvider, ( + previous, + next, + ) { + final prevList = _prevList; + final nextList = next.maybeWhen( + data: (d) => d, + orElse: () => [], + ); + if (nextList.length > prevList.length) { + final newItem = nextList.last; + _showBanner(newItem.type, newItem); + } + _prevList = nextList; + }); + return widget.child; + } +} diff --git a/pubspec.lock b/pubspec.lock index 8658d015..64730683 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: cd83f7d6bd4e4c0b0b4fef802e8796784032e1cc23d7b0e982cf5d05d9bbe182 + url: "https://pub.dev" + source: hosted + version: "1.3.66" adaptive_number: dependency: transitive description: @@ -345,6 +353,54 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.3+5" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "923085c881663ef685269b013e241b428e1fb03cdd0ebde265d9b40ff18abf80" + url: "https://pub.dev" + source: hosted + version: "4.4.0" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "83e7356c704131ca4d8d8dd57e360d8acecbca38b1a3705c7ae46cc34c708084" + url: "https://pub.dev" + source: hosted + version: "3.4.0" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: "06fad40ea14771e969a8f2bbce1944aa20ee2f4f57f4eca5b3ba346b65f3f644" + url: "https://pub.dev" + source: hosted + version: "16.1.1" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: "6c49e901c77e6e10e86d98e32056a087eb1ca1b93acdf58524f1961e617657b7" + url: "https://pub.dev" + source: hosted + version: "4.7.6" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: "2756f8fea583ffb9d294d15ddecb3a9ad429b023b70c9990c151fc92c54a32b3" + url: "https://pub.dev" + source: hosted + version: "4.1.2" fixnum: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 07d7891e..61968c0d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,11 +24,13 @@ dependencies: flutter_quill: ^11.5.0 file_picker: ^10.3.10 pdf: ^3.11.3 - printing: ^5.10.0 + printing: ^5.14.2 flutter_keyboard_visibility: ^5.4.1 awesome_snackbar_content: ^0.1.8 permission_handler: ^12.0.1 flutter_local_notifications: ^20.1.0 + firebase_core: ^4.4.0 + firebase_messaging: ^16.1.1 dev_dependencies: flutter_test: diff --git a/supabase/functions/send_fcm/index.ts b/supabase/functions/send_fcm/index.ts new file mode 100644 index 00000000..d065bf56 --- /dev/null +++ b/supabase/functions/send_fcm/index.ts @@ -0,0 +1,77 @@ +import { createClient } from 'npm:@supabase/supabase-js@2'; + +// we no longer use google-auth-library or a service account; instead +// send FCM via a legacy server key provided as an environment variable +// (FCM_SERVER_KEY). This avoids compatibility problems on the edge. + +const supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!); + +Deno.serve(async (req) => { + try { + await supabase.from('send_fcm_errors').insert({ payload: null, error: 'step1', stack: null }); + const body = await req.json(); + try { await supabase.from('send_fcm_errors').insert({ payload: body, error: 'step2', stack: null }); } catch (_) {} + + // gather tokens (same as before) + let tokens: any[] = []; + if (Array.isArray(body.tokens)) { + tokens = body.tokens; + } else if (Array.isArray(body.user_ids)) { + try { + const { data: rows } = await supabase + .from('fcm_tokens') + .select('token') + .in('user_id', body.user_ids); + if (rows) tokens = rows.map((r: any) => r.token); + try { await supabase.from('send_fcm_errors').insert({ payload: rows ?? null, error: 'step4a', stack: null }); } catch (_) {} + } catch (_) {} + } + if (body.record && body.record.user_id) { + try { + const { data: rows } = await supabase + .from('fcm_tokens') + .select('token') + .eq('user_id', body.record.user_id); + if (rows) tokens = rows.map((r: any) => r.token); + try { await supabase.from('send_fcm_errors').insert({ payload: rows ?? null, error: 'step4b', stack: null }); } catch (_) {} + } catch (_) {} + } + try { await supabase.from('send_fcm_errors').insert({ payload: tokens, error: 'step3', stack: null }); } catch (_) {} + + // build notification text + let notificationTitle = ''; + let notificationBody = ''; + if (body.record) { + notificationTitle = `New ${body.record.type}`; + notificationBody = 'You have a new update in TasQ.'; + if (body.record.ticket_id) { + notificationBody = 'You have a new update on your ticket.'; + } else if (body.record.task_id) { + notificationBody = 'You have a new update on your task.'; + } + } + try { await supabase.from('send_fcm_errors').insert({ payload: {title: notificationTitle, body: notificationBody}, error: 'step5', stack: null }); } catch (_) {} + + // use legacy server key for FCM send + const serverKey = Deno.env.get('FCM_SERVER_KEY'); + if (serverKey && tokens.length) { + const sendPromises = tokens.map((tok) => { + return fetch('https://fcm.googleapis.com/fcm/send', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `key=${serverKey}`, + }, + body: JSON.stringify({ to: tok, notification: { title: notificationTitle, body: notificationBody }, data: body.data || {} }), + }); + }); + try { + const results = await Promise.all(sendPromises); + try { await supabase.from('send_fcm_errors').insert({ payload: results, error: 'step8', stack: null }); } catch (_) {} + } catch (e) { + try { await supabase.from('send_fcm_errors').insert({ payload: e.toString(), error: 'step8error', stack: null }); } catch (_) {} + } + } + } catch (_) {} + return new Response('ok', { headers: { 'Access-Control-Allow-Origin': '*' } }); +}); diff --git a/supabase/migrations/20260223130000_add_fcm_tokens.sql b/supabase/migrations/20260223130000_add_fcm_tokens.sql new file mode 100644 index 00000000..06ae470c --- /dev/null +++ b/supabase/migrations/20260223130000_add_fcm_tokens.sql @@ -0,0 +1,11 @@ +-- create a table to hold FCM device tokens for push notifications + +create table if not exists public.fcm_tokens ( + id uuid default uuid_generate_v4() primary key, + user_id uuid references auth.users(id) on delete cascade, + token text not null, + created_at timestamptz not null default now() +); + +create unique index if not exists fcm_tokens_user_token_idx + on public.fcm_tokens(user_id, token); diff --git a/supabase/migrations/20260224120000_add_fcm_tokens_policies.sql b/supabase/migrations/20260224120000_add_fcm_tokens_policies.sql new file mode 100644 index 00000000..5f68580a --- /dev/null +++ b/supabase/migrations/20260224120000_add_fcm_tokens_policies.sql @@ -0,0 +1,20 @@ +-- enable RLS and set policies for fcm_tokens + +ALTER TABLE public.fcm_tokens ENABLE ROW LEVEL SECURITY; + +-- allow users to insert their own tokens +CREATE POLICY "Allow users insert their tokens" ON public.fcm_tokens + FOR INSERT WITH CHECK (auth.uid() = user_id); + +-- allow users to delete their own tokens (e.g. sign out) +CREATE POLICY "Allow users delete their tokens" ON public.fcm_tokens + FOR DELETE USING (auth.uid() = user_id); + +-- allow users to select their own tokens (if needed for debugging) +CREATE POLICY "Allow users select their tokens" ON public.fcm_tokens + FOR SELECT USING (auth.uid() = user_id); + +-- optionally allow update of token value for same user +CREATE POLICY "Allow users update their tokens" ON public.fcm_tokens + FOR UPDATE USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); diff --git a/supabase/migrations/20260224120000_remove_fcm_unique_index.sql b/supabase/migrations/20260224120000_remove_fcm_unique_index.sql new file mode 100644 index 00000000..35e15e5d --- /dev/null +++ b/supabase/migrations/20260224120000_remove_fcm_unique_index.sql @@ -0,0 +1,8 @@ +-- drop unique index to allow multiple entries (e.g. same token across +-- device logins) for a single user. the application already keeps tokens +-- tidy by unregistering on sign-out, so duplicates are harmless. + +drop index if exists fcm_tokens_user_token_idx; + +-- maintain a regular index on user_id for performance +create index if not exists fcm_tokens_user_idx on public.fcm_tokens(user_id); \ No newline at end of file diff --git a/supabase/migrations/20260224130000_notifications_trigger.sql b/supabase/migrations/20260224130000_notifications_trigger.sql new file mode 100644 index 00000000..ea96f8bc --- /dev/null +++ b/supabase/migrations/20260224130000_notifications_trigger.sql @@ -0,0 +1,35 @@ +-- create a trigger that calls the `send_fcm` edge function whenever a +-- new row is inserted into `notifications`. This moves push logic entirely +-- to the backend, bypassing any CORS or auth issues that occur when web +-- clients try to invoke the function directly. + +-- the http extension is available on Supabase databases; enable it if +-- it's not already installed. +create extension if not exists http; + +create or replace function public.notifications_send_fcm_trigger() +returns trigger +language plpgsql as $$ +declare + _url text := 'https://pwbxgsuskvqwwaejxutj.supabase.co/functions/v1/send_fcm'; + _body text; +begin + -- build a webhook-style payload that matches what the edge function + -- expects when it's invoked from a database trigger. + _body := json_build_object('record', row_to_json(NEW))::text; + + -- fire the POST; ignore the result since we don't want inserts to fail + -- just because the push call returned an error. + perform http_post(_url, _body, 'content-type=application/json'); + + return NEW; +end; +$$; + +-- drop any previous trigger (defensive) and create a new one. +drop trigger if exists trig_notifications_send_fcm on public.notifications; + +create trigger trig_notifications_send_fcm + after insert on public.notifications + for each row + execute function public.notifications_send_fcm_trigger(); diff --git a/supabase/migrations/20260224131500_send_fcm_errors_table.sql b/supabase/migrations/20260224131500_send_fcm_errors_table.sql new file mode 100644 index 00000000..3cff81f2 --- /dev/null +++ b/supabase/migrations/20260224131500_send_fcm_errors_table.sql @@ -0,0 +1,11 @@ +-- table for capturing errors that occur inside the send_fcm edge function +-- when it is invoked via database trigger. This makes it easier to debug +-- production failures without needing to inspect external logs. + +create table if not exists public.send_fcm_errors ( + id uuid default gen_random_uuid() primary key, + payload jsonb, + error text, + stack text, + created_at timestamptz default now() +); diff --git a/web/index.html b/web/index.html index 0bbf9812..e6324c14 100644 --- a/web/index.html +++ b/web/index.html @@ -35,7 +35,28 @@ - +