3cb980b629
Security — enforce write authorization server-side (was UI/RPC-only): - it_service_requests RLS: block cross-office read/edit + self-approve (QA-015) - pass_slips RLS: owner can complete but not self-approve (QA-046) - swap_requests RLS: scope select/update to participants + admin (QA-047) - storage: tighten it_service_attachments + task_attachments write/delete (QA-027) - admin_user_management edge function: allow programmers to manage users (QA-016) Fixes: - workforce generator "uncovered shifts" false alarms (QA-043/044) - network-map VLAN + New-location dialog validation, disabled-until-valid (QA-048) - de-flake time-of-day-dependent dashboard metrics test (QA-045) Toolchain: - upgrade to Flutter 3.47.5 / Dart 3.13.4; font_awesome_flutter 11.0.0, flutter_quill 11.6.0, pdfrx 2.6.5; clear resulting deprecations (QA-002) analyze clean; 139 tests pass; web build succeeds. Report + evidence in docs/qa/. Note: also carries the in-progress Brick model cleanup already present in the working tree. QA-001 (AI keys public in the build) is deferred by owner decision. Co-Authored-By: claude-flow <ruv@ruv.net>
202 lines
6.7 KiB
Dart
202 lines
6.7 KiB
Dart
import 'package:flutter/foundation.dart' show debugPrint;
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
import '../brick/cache_helpers.dart';
|
|
import '../models/chat_message.model.dart';
|
|
import '../utils/app_time.dart';
|
|
import '../utils/snackbar.dart' show isOfflineSaveError;
|
|
import 'connectivity_provider.dart';
|
|
import 'supabase_provider.dart';
|
|
import 'stream_recovery.dart';
|
|
import 'realtime_controller.dart';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Offline-pending state
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Pending chat messages keyed by threadId.
|
|
/// Messages sync on reconnect when the thread's provider is active.
|
|
final offlinePendingChatMessagesProvider =
|
|
StateProvider<Map<String, List<ChatMessage>>>((ref) => const {});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Read provider
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Real-time chat messages for a swap request thread.
|
|
final chatMessagesProvider = StreamProvider.family<List<ChatMessage>, String>((
|
|
ref,
|
|
threadId,
|
|
) {
|
|
final client = ref.watch(supabaseClientProvider);
|
|
|
|
final pendingByThread = ref.watch(offlinePendingChatMessagesProvider);
|
|
final pendingForThread = pendingByThread[threadId] ?? const [];
|
|
|
|
List<ChatMessage> applyPending(List<ChatMessage> rows) {
|
|
if (pendingForThread.isEmpty) return rows;
|
|
final byId = {for (final r in rows) r.id: r};
|
|
for (final p in pendingForThread) {
|
|
byId.putIfAbsent(p.id, () => p);
|
|
}
|
|
return byId.values.toList()
|
|
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
|
}
|
|
|
|
// Newest-first everywhere: the swap chat renders with ListView(reverse: true).
|
|
final wrapper = StreamRecoveryWrapper<ChatMessage>(
|
|
stream: client
|
|
.from('chat_messages')
|
|
.stream(primaryKey: ['id'])
|
|
.eq('thread_id', threadId)
|
|
.order('created_at', ascending: false),
|
|
onPollData: () async {
|
|
final data = await client
|
|
.from('chat_messages')
|
|
.select()
|
|
.eq('thread_id', threadId)
|
|
.order('created_at', ascending: false);
|
|
return data.map(ChatMessage.fromMap).toList();
|
|
},
|
|
fromMap: ChatMessage.fromMap,
|
|
channelName: 'chat_messages_$threadId',
|
|
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
|
onOfflineData: () async {
|
|
final all = await cachedListFromBrick<ChatMessage>();
|
|
final filtered = all.where((m) => m.threadId == threadId).toList()
|
|
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
|
return applyPending(filtered);
|
|
},
|
|
onCacheMirror: (rows) =>
|
|
mirrorBatchToBrick<ChatMessage>(rows, tag: 'chat_messages'),
|
|
);
|
|
|
|
ref.onDispose(wrapper.dispose);
|
|
|
|
// Reconnect-replay: fires when this thread's screen is active.
|
|
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
|
|
if (wasOnline == true || !isNowOnline) return;
|
|
|
|
final pending = List<ChatMessage>.from(
|
|
(ref.read(offlinePendingChatMessagesProvider)[threadId] ?? []),
|
|
);
|
|
if (pending.isEmpty) return;
|
|
|
|
debugPrint(
|
|
'[chatMessagesProvider($threadId)] reconnected — syncing ${pending.length} message(s)',
|
|
);
|
|
final synced = <String>[];
|
|
for (final msg in pending) {
|
|
try {
|
|
await client.from('chat_messages').insert({
|
|
'id': msg.id,
|
|
'thread_id': msg.threadId,
|
|
'sender_id': msg.senderId,
|
|
'body': msg.body,
|
|
});
|
|
synced.add(msg.id);
|
|
} catch (e) {
|
|
final s = e.toString();
|
|
if (s.contains('23505') || s.contains('duplicate key')) {
|
|
synced.add(msg.id);
|
|
} else {
|
|
debugPrint(
|
|
'[chatMessagesProvider] failed to sync message id=${msg.id}: $e',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (synced.isNotEmpty) {
|
|
final current = Map<String, List<ChatMessage>>.from(
|
|
ref.read(offlinePendingChatMessagesProvider),
|
|
);
|
|
final remaining =
|
|
(current[threadId] ?? []).where((m) => !synced.contains(m.id)).toList();
|
|
if (remaining.isEmpty) {
|
|
current.remove(threadId);
|
|
} else {
|
|
current[threadId] = remaining;
|
|
}
|
|
ref.read(offlinePendingChatMessagesProvider.notifier).state =
|
|
Map.unmodifiable(current);
|
|
}
|
|
});
|
|
|
|
return wrapper.stream.map((result) => applyPending(result.data));
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Controller
|
|
// ---------------------------------------------------------------------------
|
|
|
|
final chatControllerProvider = Provider<ChatController>((ref) {
|
|
final client = ref.watch(supabaseClientProvider);
|
|
return ChatController(client, ref);
|
|
});
|
|
|
|
class ChatController {
|
|
ChatController(this._client, [this._ref]);
|
|
|
|
final SupabaseClient _client;
|
|
final Ref? _ref;
|
|
|
|
/// Send a message to a thread. Offline: queues locally.
|
|
Future<void> sendMessage({
|
|
required String threadId,
|
|
required String body,
|
|
}) async {
|
|
final userId = _client.auth.currentUser?.id;
|
|
if (userId == null) throw Exception('Not authenticated');
|
|
|
|
final id = const Uuid().v4();
|
|
|
|
try {
|
|
await _client.from('chat_messages').insert({
|
|
'id': id,
|
|
'thread_id': threadId,
|
|
'sender_id': userId,
|
|
'body': body,
|
|
});
|
|
} catch (e) {
|
|
if (!isOfflineSaveError(e)) rethrow;
|
|
|
|
final ref = _ref;
|
|
if (ref != null) {
|
|
final existing =
|
|
ref.read(offlinePendingChatMessagesProvider)[threadId] ?? [];
|
|
// Idempotency guard: suppress if the same body was queued within the
|
|
// last 10 s — prevents duplicate messages from rapid taps while offline.
|
|
final cutoff = AppTime.now().subtract(const Duration(seconds: 10));
|
|
if (existing.any((m) => m.body == body && m.createdAt.isAfter(cutoff))) {
|
|
debugPrint(
|
|
'[ChatController] duplicate offline message suppressed for thread=$threadId',
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
final message = ChatMessage(
|
|
id: id,
|
|
threadId: threadId,
|
|
senderId: userId,
|
|
body: body,
|
|
createdAt: AppTime.now(),
|
|
);
|
|
|
|
final ref2 = _ref;
|
|
if (ref2 != null) {
|
|
final current = Map<String, List<ChatMessage>>.from(
|
|
ref2.read(offlinePendingChatMessagesProvider),
|
|
);
|
|
current[threadId] = [...(current[threadId] ?? []), message];
|
|
ref2.read(offlinePendingChatMessagesProvider.notifier).state =
|
|
Map.unmodifiable(current);
|
|
}
|
|
mirrorBatchToBrick<ChatMessage>([message], tag: 'offline_chat');
|
|
debugPrint('[ChatController] sendMessage queued offline: $id');
|
|
}
|
|
}
|
|
}
|