tasq/lib/providers/tickets_provider.dart

744 lines
23 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 '../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 'stream_recovery.dart';
import 'realtime_controller.dart';
final officesProvider = StreamProvider<List<Office>>((ref) {
final client = ref.watch(supabaseClientProvider);
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,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => 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);
});
/// 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 {
final data = await client.from('tickets').select();
return data.cast<Map<String, dynamic>>().map(Ticket.fromMap).toList();
},
fromMap: Ticket.fromMap,
channelName: 'tickets',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
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 {
try {
final data = await client.from('tickets').select();
final raw = data
.cast<Map<String, dynamic>>()
.map(Ticket.fromMap)
.toList();
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);
},
);
final ticketsControllerProvider = Provider<TicketsController>((ref) {
final client = ref.watch(supabaseClientProvider);
return TicketsController(client);
});
class TicketsController {
TicketsController(this._client);
final SupabaseClient _client;
Future<void> createTicket({
required String subject,
required String description,
required String officeId,
}) async {
final actorId = _client.auth.currentUser?.id;
final data = await _client
.from('tickets')
.insert({
'subject': subject,
'description': description,
'office_id': officeId,
'creator_id': _client.auth.currentUser?.id,
})
.select('id')
.single();
final ticketId = data['id'] as String?;
if (ticketId == null) return;
unawaited(_notifyCreated(ticketId: ticketId, actorId: actorId));
}
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 payload = <String, dynamic>{
'task_id': taskId,
'content': content,
'sender_id': _client.auth.currentUser?.id,
};
if (ticketId != null) {
payload['ticket_id'] = ticketId;
}
final data = await _client
.from('ticket_messages')
.insert(payload)
.select()
.single();
return TicketMessage.fromMap(data);
}
Future<void> updateTicketStatus({
required String ticketId,
required String status,
}) async {
await _client.from('tickets').update({'status': status}).eq('id', ticketId);
// 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.
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 {
OfficesController(this._client);
final SupabaseClient _client;
Future<void> createOffice({required String name, String? serviceId}) async {
final payload = {'name': name};
if (serviceId != null) payload['service_id'] = serviceId;
await _client.from('offices').insert(payload);
}
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);
}
}