Files
tasq/lib/providers/tickets_provider.dart
T
2026-04-27 06:20:39 +08:00

1067 lines
34 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import '../brick/cache_helpers.dart';
import '../brick/repository.dart';
import '../utils/snackbar.dart' show isOfflineSaveError;
import '../models/office.dart';
import '../models/ticket.dart';
import '../models/ticket_message.dart';
import 'profile_provider.dart';
import 'supabase_provider.dart';
import 'user_offices_provider.dart';
import 'tasks_provider.dart';
import 'connectivity_provider.dart';
import 'stream_recovery.dart';
import 'realtime_controller.dart';
/// Pending offices created while offline.
final offlinePendingOfficesProvider =
StateProvider<List<Office>>((ref) => const []);
final officesProvider = StreamProvider<List<Office>>((ref) {
final client = ref.watch(supabaseClientProvider);
final pendingOffices = ref.watch(offlinePendingOfficesProvider);
List<Office> applyPending(List<Office> rows) {
if (pendingOffices.isEmpty) return rows;
final byId = {for (final r in rows) r.id: r};
for (final p in pendingOffices) {
byId.putIfAbsent(p.id, () => p);
}
return byId.values.toList()
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
}
final wrapper = StreamRecoveryWrapper<Office>(
stream: client.from('offices').stream(primaryKey: ['id']).order('name'),
onPollData: () async {
final data = await client.from('offices').select().order('name');
return data.map(Office.fromMap).toList();
},
fromMap: Office.fromMap,
channelName: 'offices',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<Office>();
all.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return applyPending(all);
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<Office>(rows, tag: 'offices'),
);
ref.onDispose(wrapper.dispose);
// Reconnect-replay for offline-created offices.
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
if (wasOnline == true || !isNowOnline) return;
final pending = List<Office>.from(
ref.read(offlinePendingOfficesProvider),
);
if (pending.isEmpty) return;
final synced = <String>[];
for (final office in pending) {
try {
final payload = <String, dynamic>{'id': office.id, 'name': office.name};
if (office.serviceId != null) payload['service_id'] = office.serviceId;
await client.from('offices').insert(payload);
synced.add(office.id);
} catch (e) {
final s = e.toString();
if (s.contains('23505') || s.contains('duplicate key')) {
synced.add(office.id);
}
}
}
if (synced.isNotEmpty) {
final remaining = ref
.read(offlinePendingOfficesProvider)
.where((o) => !synced.contains(o.id))
.toList();
ref.read(offlinePendingOfficesProvider.notifier).state =
List.unmodifiable(remaining);
}
});
return wrapper.stream.map((result) => applyPending(result.data));
});
final officesOnceProvider = FutureProvider<List<Office>>((ref) async {
final client = ref.watch(supabaseClientProvider);
final rows = await client.from('offices').select().order('name');
return (rows as List<dynamic>)
.map((row) => Office.fromMap(row as Map<String, dynamic>))
.toList();
});
/// Office query parameters for server-side pagination.
class OfficeQuery {
/// Creates office query parameters.
const OfficeQuery({this.offset = 0, this.limit = 50, this.searchQuery = ''});
/// Offset for pagination.
final int offset;
/// Number of items per page (default: 50).
final int limit;
/// Full text search query.
final String searchQuery;
OfficeQuery copyWith({int? offset, int? limit, String? searchQuery}) {
return OfficeQuery(
offset: offset ?? this.offset,
limit: limit ?? this.limit,
searchQuery: searchQuery ?? this.searchQuery,
);
}
}
final officesQueryProvider = StateProvider<OfficeQuery>(
(ref) => const OfficeQuery(),
);
final officesControllerProvider = Provider<OfficesController>((ref) {
final client = ref.watch(supabaseClientProvider);
return OfficesController(client, ref);
});
/// Ticket query parameters for server-side pagination and filtering.
class TicketQuery {
/// Creates ticket query parameters.
const TicketQuery({
this.offset = 0,
this.limit = 50,
this.searchQuery = '',
this.officeId,
this.status,
this.dateRange,
});
/// Offset for pagination.
final int offset;
/// Number of items per page (default: 50).
final int limit;
/// Full text search query.
final String searchQuery;
/// Filter by office ID.
final String? officeId;
/// Filter by status.
final String? status;
/// Filter by date range.
/// Filter by date range.
final DateTimeRange? dateRange;
TicketQuery copyWith({
int? offset,
int? limit,
String? searchQuery,
String? officeId,
String? status,
DateTimeRange? dateRange,
}) {
return TicketQuery(
offset: offset ?? this.offset,
limit: limit ?? this.limit,
searchQuery: searchQuery ?? this.searchQuery,
officeId: officeId ?? this.officeId,
status: status ?? this.status,
dateRange: dateRange ?? this.dateRange,
);
}
}
/// Builds the isolate payload from a list of [Ticket] objects and the current
/// query/access context. Extracted so the initial REST seed and the realtime
/// stream listener can share the same logic without duplication.
Map<String, dynamic> _buildTicketPayload({
required List<Ticket> tickets,
required bool isGlobal,
required List<String> allowedOfficeIds,
required TicketQuery query,
}) {
final rowsList = tickets
.map(
(ticket) => <String, dynamic>{
'id': ticket.id,
'subject': ticket.subject,
'description': ticket.description,
'status': ticket.status,
'office_id': ticket.officeId,
'creator_id': ticket.creatorId,
'created_at': ticket.createdAt.toIso8601String(),
'responded_at': ticket.respondedAt?.toIso8601String(),
'promoted_at': ticket.promotedAt?.toIso8601String(),
'closed_at': ticket.closedAt?.toIso8601String(),
},
)
.toList();
return {
'rows': rowsList,
'isGlobal': isGlobal,
'allowedOfficeIds': allowedOfficeIds,
'offset': query.offset,
'limit': query.limit,
'searchQuery': query.searchQuery,
'officeId': query.officeId,
'status': query.status,
'dateStart': query.dateRange?.start.millisecondsSinceEpoch,
'dateEnd': query.dateRange?.end.millisecondsSinceEpoch,
};
}
final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider);
final assignmentsAsync = ref.watch(userOfficesProvider);
final query = ref.watch(ticketsQueryProvider);
final profile = profileAsync.valueOrNull;
if (profile == null) {
return Stream.value(const <Ticket>[]);
}
final isGlobal =
profile.role == 'admin' ||
profile.role == 'programmer' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff';
final allowedOfficeIds =
assignmentsAsync.valueOrNull
?.where((a) => a.userId == profile.id)
.map((a) => a.officeId)
.toList() ??
<String>[];
// Wrap realtime stream with recovery logic
final wrapper = StreamRecoveryWrapper<Ticket>(
stream: client.from('tickets').stream(primaryKey: ['id']),
onPollData: () async {
// Apply server-side filters to reduce poll payload.
// Role-based filtering and complex searches still run in the isolate.
var q = client.from('tickets').select();
if (query.status != null) q = q.eq('status', query.status!);
if (query.officeId != null) q = q.eq('office_id', query.officeId!);
if (query.searchQuery.isNotEmpty) {
q = q.or(
'subject.ilike.%${query.searchQuery}%,'
'description.ilike.%${query.searchQuery}%',
);
}
final data = await q
.order('created_at', ascending: false)
.range(0, kTaskPageLimit - 1);
return data.cast<Map<String, dynamic>>().map(Ticket.fromMap).toList();
},
fromMap: Ticket.fromMap,
channelName: 'tickets',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () => cachedListFromBrick<Ticket>(),
onCacheMirror: (rows) =>
mirrorBatchToBrick<Ticket>(rows, tag: 'tickets'),
);
ref.onDispose(wrapper.dispose);
// Reconnect-replay: POST queued items directly, bypassing Brick's queue.
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
if (wasOnline == true || !isNowOnline) return;
// 1. Replay offline-created tickets
final pendingRaw = List<Map<String, dynamic>>.from(
ref.read(offlinePendingTicketRawProvider),
);
if (pendingRaw.isNotEmpty) {
debugPrint(
'[ticketsProvider] reconnected — syncing ${pendingRaw.length} ticket(s)',
);
final syncedIds = <String>[];
for (final row in pendingRaw) {
final id = row['id'] as String;
try {
await client.from('tickets').insert(row);
syncedIds.add(id);
} catch (e) {
final msg = e.toString();
if (msg.contains('23505') || msg.contains('duplicate key')) {
syncedIds.add(id);
} else {
debugPrint('[ticketsProvider] failed to sync ticket id=$id: $e');
}
}
}
if (syncedIds.isNotEmpty) {
final remainingRaw = ref
.read(offlinePendingTicketRawProvider)
.where((r) => !syncedIds.contains(r['id'] as String))
.toList();
ref.read(offlinePendingTicketRawProvider.notifier).state =
List.unmodifiable(remainingRaw);
final remainingModels = ref
.read(offlinePendingTicketsProvider)
.where((t) => !syncedIds.contains(t.id))
.toList();
ref.read(offlinePendingTicketsProvider.notifier).state =
List.unmodifiable(remainingModels);
}
}
// 2. Replay offline ticket updates
final pendingUpdates = Map<String, Map<String, dynamic>>.from(
ref.read(offlinePendingTicketUpdatesProvider),
);
if (pendingUpdates.isNotEmpty) {
debugPrint(
'[ticketsProvider] reconnected — syncing ${pendingUpdates.length} ticket update(s)',
);
final syncedIds = <String>[];
for (final entry in pendingUpdates.entries) {
try {
await client
.from('tickets')
.update(entry.value)
.eq('id', entry.key);
syncedIds.add(entry.key);
} catch (e) {
debugPrint(
'[ticketsProvider] failed to sync update id=${entry.key}: $e',
);
}
}
if (syncedIds.isNotEmpty) {
final remaining = Map<String, Map<String, dynamic>>.from(
ref.read(offlinePendingTicketUpdatesProvider),
);
for (final id in syncedIds) {
remaining.remove(id);
}
ref.read(offlinePendingTicketUpdatesProvider.notifier).state = remaining;
}
}
});
var lastResultHash = '';
Timer? debounceTimer;
// broadcast() so Riverpod and any other listener can both receive events.
final controller = StreamController<List<Ticket>>.broadcast();
void emitDebounced(List<Ticket> tickets) {
debounceTimer?.cancel();
debounceTimer = Timer(const Duration(milliseconds: 150), () {
if (!controller.isClosed) controller.add(tickets);
});
}
ref.onDispose(() {
debounceTimer?.cancel();
controller.close();
});
// ── Immediate REST seed ───────────────────────────────────────────────────
// Fire a one-shot HTTP fetch right now so the UI can render before the
// WebSocket realtime channel is fully established. This eliminates the
// loading delay on web (WebSocket ~200-500 ms) and the initial flash on
// mobile. The realtime stream takes over afterwards; the hash check below
// prevents a duplicate rebuild if both arrive with identical data.
unawaited(
Future(() async {
// When offline, seed from Brick local SQLite cache instead of network.
if (!ref.read(isOnlineProvider) && AppRepository.isConfigured) {
try {
final cached = await AppRepository.instance.get<Ticket>(
policy: OfflineFirstGetPolicy.localOnly,
);
debugPrint('[ticketsProvider] offline seed: ${cached.length} tickets from local cache');
final payload = _buildTicketPayload(
tickets: cached,
isGlobal: isGlobal,
allowedOfficeIds: allowedOfficeIds,
query: query,
);
final processed = await compute(_processTicketsInIsolate, payload);
final tickets = (processed as List<dynamic>)
.cast<Map<String, dynamic>>()
.map(Ticket.fromMap)
.toList();
final hash = tickets.fold('', (h, t) => '$h${t.id}');
if (!controller.isClosed && hash != lastResultHash) {
lastResultHash = hash;
controller.add(tickets);
}
} catch (e) {
debugPrint('[ticketsProvider] offline seed error: $e');
}
return;
}
try {
// Seed fetch: order newest-first and limit to 200 rows to reduce
// initial payload. The realtime stream delivers subsequent changes.
var seedQ = client.from('tickets').select();
if (query.status != null) seedQ = seedQ.eq('status', query.status!);
if (query.officeId != null) {
seedQ = seedQ.eq('office_id', query.officeId!);
}
final data = await seedQ
.order('created_at', ascending: false)
.range(0, kTaskPageLimit - 1);
final raw = data
.cast<Map<String, dynamic>>()
.map(Ticket.fromMap)
.toList();
if (raw.length >= kTaskPageLimit) {
debugPrint(
'[ticketsProvider] seed hit $kTaskPageLimit-row limit; '
'older tickets arrive via realtime stream.',
);
}
final payload = _buildTicketPayload(
tickets: raw,
isGlobal: isGlobal,
allowedOfficeIds: allowedOfficeIds,
query: query,
);
final processed = await compute(_processTicketsInIsolate, payload);
final tickets = (processed as List<dynamic>)
.cast<Map<String, dynamic>>()
.map(Ticket.fromMap)
.toList();
final hash = tickets.fold('', (h, t) => '$h${t.id}');
if (!controller.isClosed && hash != lastResultHash) {
lastResultHash = hash;
controller.add(tickets); // emit immediately no debounce
}
} catch (e) {
debugPrint('[ticketsProvider] initial seed error: $e');
}
}),
);
// ── Realtime stream ───────────────────────────────────────────────────────
// Processes every realtime event through the same isolate. Debounced so
// rapid consecutive events (e.g. bulk inserts) don't cause repeated renders.
final wrapperSub = wrapper.stream
.asyncMap((result) async {
final payload = _buildTicketPayload(
tickets: result.data,
isGlobal: isGlobal,
allowedOfficeIds: allowedOfficeIds,
query: query,
);
final processed = await compute(_processTicketsInIsolate, payload);
return (processed as List<dynamic>)
.cast<Map<String, dynamic>>()
.map(Ticket.fromMap)
.toList();
})
.listen(
(tickets) {
final hash = tickets.fold('', (h, t) => '$h${t.id}');
if (hash != lastResultHash) {
lastResultHash = hash;
emitDebounced(tickets);
}
},
onError: (Object e) {
debugPrint('[ticketsProvider] stream error: $e');
// Don't forward errors — the wrapper handles recovery internally.
},
);
ref.onDispose(wrapperSub.cancel);
return controller.stream;
});
// Runs inside a background isolate. Accepts a serializable payload and
// returns a list of ticket maps after filtering/sorting/pagination.
List<Map<String, dynamic>> _processTicketsInIsolate(
Map<String, dynamic> payload,
) {
final rows = (payload['rows'] as List).cast<Map<String, dynamic>>();
var list = List<Map<String, dynamic>>.from(rows);
final isGlobal = payload['isGlobal'] as bool? ?? false;
final allowedOfficeIds =
(payload['allowedOfficeIds'] as List?)?.cast<String>().toSet() ??
<String>{};
if (!isGlobal) {
if (allowedOfficeIds.isEmpty) {
return <Map<String, dynamic>>[];
}
list = list
.where((t) => allowedOfficeIds.contains(t['office_id']))
.toList();
}
final officeId = payload['officeId'] as String?;
if (officeId != null) {
list = list.where((t) => t['office_id'] == officeId).toList();
}
final status = payload['status'] as String?;
if (status != null) {
list = list.where((t) => t['status'] == status).toList();
}
final searchQuery = (payload['searchQuery'] as String?) ?? '';
if (searchQuery.isNotEmpty) {
final q = searchQuery.toLowerCase();
list = list.where((t) {
final subj = (t['subject'] as String?)?.toLowerCase() ?? '';
final desc = (t['description'] as String?)?.toLowerCase() ?? '';
return subj.contains(q) || desc.contains(q);
}).toList();
}
// Parse created_at timestamp from map. `created_at` may be ISO strings or timestamps.
int parseCreatedAt(Map<String, dynamic> m) {
final v = m['created_at'];
if (v == null) return 0;
if (v is int) return v;
if (v is double) return v.toInt();
if (v is String) {
try {
return DateTime.parse(v).millisecondsSinceEpoch;
} catch (_) {
return 0;
}
}
return 0;
}
int statusRank(String s) {
switch (s) {
case 'pending':
return 0;
case 'promoted':
return 1;
case 'closed':
return 2;
default:
return 3;
}
}
// Sort by status rank first (pending → promoted → closed),
// then by created_at descending (newest first)
list.sort((a, b) {
final ra = statusRank((a['status'] as String?) ?? '');
final rb = statusRank((b['status'] as String?) ?? '');
final rcmp = ra.compareTo(rb);
if (rcmp != 0) return rcmp;
// Same status: sort by created_at descending (newest first)
return parseCreatedAt(b).compareTo(parseCreatedAt(a));
});
final start = (payload['offset'] as int?) ?? 0;
final limit = (payload['limit'] as int?) ?? 50;
final end = (start + limit).clamp(0, list.length);
if (start >= list.length) return <Map<String, dynamic>>[];
return list.sublist(start, end);
}
/// Provider for ticket query parameters.
final ticketsQueryProvider = StateProvider<TicketQuery>(
(ref) => const TicketQuery(),
);
/// Derived provider that selects a single [Ticket] by ID from the tickets list.
///
/// Because [Ticket] implements `==`, this provider only notifies watchers when
/// the specific ticket's data actually changes — not when unrelated tickets in
/// the list are updated. Use this in detail screens to avoid full-list rebuilds.
final ticketByIdProvider = Provider.family<Ticket?, String>((ref, ticketId) {
return ref
.watch(ticketsProvider)
.valueOrNull
?.where((t) => t.id == ticketId)
.firstOrNull;
});
final ticketMessagesProvider =
StreamProvider.family<List<TicketMessage>, String>((ref, ticketId) {
final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<TicketMessage>(
stream: client
.from('ticket_messages')
.stream(primaryKey: ['id'])
.eq('ticket_id', ticketId)
.order('created_at', ascending: false),
onPollData: () async {
final data = await client
.from('ticket_messages')
.select()
.eq('ticket_id', ticketId)
.order('created_at', ascending: false);
return data.map(TicketMessage.fromMap).toList();
},
fromMap: TicketMessage.fromMap,
channelName: 'ticket_messages:$ticketId',
onStatusChanged: ref
.read(realtimeControllerProvider)
.handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
});
final ticketMessagesAllProvider = StreamProvider<List<TicketMessage>>((ref) {
final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<TicketMessage>(
stream: client
.from('ticket_messages')
.stream(primaryKey: ['id'])
.order('created_at', ascending: false),
onPollData: () async {
final data = await client
.from('ticket_messages')
.select()
.order('created_at', ascending: false);
return data.map(TicketMessage.fromMap).toList();
},
fromMap: TicketMessage.fromMap,
channelName: 'ticket_messages_all',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
});
final taskMessagesProvider = StreamProvider.family<List<TicketMessage>, String>(
(ref, taskId) {
final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<TicketMessage>(
stream: client
.from('ticket_messages')
.stream(primaryKey: ['id'])
.eq('task_id', taskId)
.order('created_at', ascending: false),
onPollData: () async {
final data = await client
.from('ticket_messages')
.select()
.eq('task_id', taskId)
.order('created_at', ascending: false);
return data.map(TicketMessage.fromMap).toList();
},
fromMap: TicketMessage.fromMap,
channelName: 'task_messages:$taskId',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
},
);
/// Tickets created offline that haven't synced to Supabase yet.
/// Merged into the live stream in the UI — cleared entry-by-entry as the
/// realtime stream delivers each synced UUID.
final offlinePendingTicketsProvider =
StateProvider<List<Ticket>>((ref) => const []);
/// Raw INSERT payloads for offline-created tickets — used for reconnect replay.
final offlinePendingTicketRawProvider =
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
/// Field-level updates to existing tickets made offline, keyed by ticket id.
final offlinePendingTicketUpdatesProvider =
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
final ticketsControllerProvider = Provider<TicketsController>((ref) {
final client = ref.watch(supabaseClientProvider);
return TicketsController(client, ref);
});
class TicketsController {
TicketsController(this._client, [this._ref]);
final SupabaseClient _client;
final Ref? _ref;
/// Creates a ticket. Returns the new [Ticket] so callers can add it to
/// [offlinePendingTicketsProvider] when offline.
Future<Ticket?> createTicket({
required String subject,
required String description,
required String officeId,
}) async {
final actorId = _client.auth.currentUser?.id;
// Generate UUID before the network call so Brick's offline queue captures
// the same UUID in the queued request — preventing a UUID mismatch on replay.
final id = const Uuid().v4();
try {
final data = await _client
.from('tickets')
.insert({
'id': id,
'subject': subject,
'description': description,
'office_id': officeId,
'creator_id': actorId,
})
.select('id')
.single();
final ticketId = data['id'] as String?;
if (ticketId == null) return null;
unawaited(_notifyCreated(ticketId: ticketId, actorId: actorId));
return null; // online path — ticket will arrive via realtime stream
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
final ticket = Ticket(
id: id,
subject: subject,
description: description,
officeId: officeId,
creatorId: actorId ?? '',
status: 'pending',
createdAt: DateTime.now().toUtc(),
respondedAt: null,
promotedAt: null,
closedAt: null,
);
// Queue for reconnect replay and persist locally for cold-launch.
final ref = _ref;
if (ref != null) {
final currentRaw = List<Map<String, dynamic>>.from(
ref.read(offlinePendingTicketRawProvider),
);
currentRaw.add({
'id': id,
'subject': subject,
'description': description,
'office_id': officeId,
'creator_id': actorId,
});
ref.read(offlinePendingTicketRawProvider.notifier).state =
List.unmodifiable(currentRaw);
}
mirrorBatchToBrick<Ticket>([ticket], tag: 'offline_ticket');
return ticket;
}
}
Future<void> _notifyCreated({
required String ticketId,
required String? actorId,
}) async {
try {
final recipients = await _fetchRoleUserIds(
roles: const ['dispatcher', 'it_staff'],
excludeUserId: actorId,
);
if (recipients.isEmpty) return;
final rows = recipients
.map(
(userId) => {
'user_id': userId,
'actor_id': actorId,
'ticket_id': ticketId,
'type': 'created',
},
)
.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,
'ticket_number': ?ticketNumber,
'type': 'created',
},
},
);
} catch (e) {
// non-fatal
debugPrint('ticket notifyCreated push error: $e');
}
} catch (_) {
return;
}
}
Future<List<String>> _fetchRoleUserIds({
required List<String> roles,
required String? excludeUserId,
}) async {
try {
final data = await _client
.from('profiles')
.select('id, role')
.inFilter('role', roles);
final rows = data as List<dynamic>;
final ids = rows
.map((row) => row['id'] as String?)
.whereType<String>()
.where((id) => id.isNotEmpty && id != excludeUserId)
.toList();
return ids;
} catch (_) {
return [];
}
}
Future<TicketMessage> sendTicketMessage({
required String ticketId,
required String content,
}) async {
final data = await _client
.from('ticket_messages')
.insert({
'ticket_id': ticketId,
'content': content,
'sender_id': _client.auth.currentUser?.id,
})
.select()
.single();
return TicketMessage.fromMap(data);
}
Future<TicketMessage> sendTaskMessage({
required String taskId,
required String? ticketId,
required String content,
}) async {
final senderId = _client.auth.currentUser?.id;
final payload = <String, dynamic>{
'task_id': taskId,
'content': content,
'sender_id': senderId,
};
if (ticketId != null) {
payload['ticket_id'] = ticketId;
}
try {
final data = await _client
.from('ticket_messages')
.insert(payload)
.select()
.single();
return TicketMessage.fromMap(data);
} catch (e) {
if (isOfflineSaveError(e)) {
// Store message locally so it appears in the UI immediately and is
// replayed by tasksProvider's reconnect listener when back online.
final ref = _ref;
if (ref != null) {
final createdAt = DateTime.now().toUtc().toIso8601String();
final msgPayload = Map<String, dynamic>.from(payload)
..['created_at'] = createdAt;
final current = Map<String, List<Map<String, dynamic>>>.from(
ref.read(offlinePendingMessagesProvider),
);
current[taskId] = [...(current[taskId] ?? []), msgPayload];
ref.read(offlinePendingMessagesProvider.notifier).state = current;
// Return a synthetic TicketMessage so the caller's mention logic
// still has a message object to work with.
return TicketMessage(
id: -DateTime.now().millisecondsSinceEpoch,
ticketId: ticketId,
taskId: taskId,
senderId: senderId,
content: content,
createdAt: DateTime.now(),
);
}
}
rethrow;
}
}
Future<void> updateTicketStatus({
required String ticketId,
required String status,
}) async {
try {
await _client
.from('tickets')
.update({'status': status})
.eq('id', ticketId);
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
_queueTicketUpdate(ticketId, {'status': status});
return;
}
// If ticket is promoted, create a linked Task (only once) — the
// TasksController.createTask already runs auto-assignment on creation.
if (status == 'promoted') {
try {
final existing = await _client
.from('tasks')
.select('id')
.eq('ticket_id', ticketId)
.maybeSingle();
if (existing != null) return;
final ticketRow = await _client
.from('tickets')
.select('subject, description, office_id')
.eq('id', ticketId)
.maybeSingle();
final title = (ticketRow?['subject'] as String?) ?? 'Task from ticket';
final description = (ticketRow?['description'] as String?) ?? '';
final officeId = ticketRow?['office_id'] as String?;
final tasksCtrl = TasksController(_client);
await tasksCtrl.createTask(
title: title,
description: description,
officeId: officeId,
ticketId: ticketId,
);
} catch (_) {
// best-effort — don't fail the ticket status update
}
}
}
/// Update editable ticket fields such as subject, description, and office.
/// Offline: queues the field patch.
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;
try {
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 (_) {}
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
_queueTicketUpdate(ticketId, payload);
}
}
void _queueTicketUpdate(String ticketId, Map<String, dynamic> fields) {
final ref = _ref;
if (ref == null) return;
final current = Map<String, Map<String, dynamic>>.from(
ref.read(offlinePendingTicketUpdatesProvider),
);
current[ticketId] = {...?current[ticketId], ...fields};
ref.read(offlinePendingTicketUpdatesProvider.notifier).state = current;
}
}
class OfficesController {
OfficesController(this._client, [this._ref]);
final SupabaseClient _client;
final Ref? _ref;
Future<void> createOffice({required String name, String? serviceId}) async {
final id = const Uuid().v4();
final payload = <String, dynamic>{'id': id, 'name': name};
if (serviceId != null) payload['service_id'] = serviceId;
try {
await _client.from('offices').insert(payload);
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
final ref = _ref;
if (ref == null) return;
final office = Office(id: id, name: name, serviceId: serviceId);
final current = List<Office>.from(
ref.read(offlinePendingOfficesProvider),
);
current.add(office);
ref.read(offlinePendingOfficesProvider.notifier).state =
List.unmodifiable(current);
mirrorBatchToBrick<Office>([office], tag: 'offline_offices');
}
}
Future<void> updateOffice({
required String id,
required String name,
String? serviceId,
}) async {
final payload = {'name': name};
if (serviceId != null) payload['service_id'] = serviceId;
await _client.from('offices').update(payload).eq('id', id);
}
Future<void> deleteOffice({required String id}) async {
await _client.from('offices').delete().eq('id', id);
}
}