* Push Notification Setup and attempt

* Office Ordering
* Allow editing of Task and Ticket Details after creation
This commit is contained in:
2026-02-24 21:06:46 +08:00
parent cc6fda0e79
commit 5979a04254
25 changed files with 1130 additions and 91 deletions
+164 -18
View File
@@ -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<void> _createAndPush(
List<Map<String, dynamic>> rows, {
List<String>? targetUserIds,
String? pushTitle,
String? pushBody,
Map<String, dynamic>? 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<void> createNotification({
required List<String> userIds,
required String type,
required String actorId,
Map<String, dynamic>? fields,
String? pushTitle,
String? pushBody,
Map<String, dynamic>? pushData,
}) async {
debugPrint(
'createNotification called type=$type users=${userIds.length} pushTitle=$pushTitle pushBody=$pushBody',
);
if (userIds.isEmpty) return;
final rows = userIds.map((userId) {
return <String, dynamic>{
'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<void> createMentionNotifications({
required List<String> 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<void> 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<void> 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<void> 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<void> sendPush({
List<String>? tokens,
List<String>? userIds,
required String title,
required String body,
Map<String, dynamic>? 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 = <String, dynamic>{
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');
}
}
}
+29
View File
@@ -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<void> updateTaskFields({
required String taskId,
String? title,
String? description,
String? officeId,
String? ticketId,
}) async {
final payload = <String, dynamic>{};
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<void> _autoAssignTask({
required String taskId,
+26
View File
@@ -370,6 +370,32 @@ class TicketsController {
}
}
}
/// Update editable ticket fields such as subject, description, and office.
Future<void> updateTicket({
required String ticketId,
String? subject,
String? description,
String? officeId,
}) async {
final payload = <String, dynamic>{};
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 {