import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:flutter/material.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'; final officesProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); return client .from('offices') .stream(primaryKey: ['id']) .order('name') .map((rows) => rows.map(Office.fromMap).toList()); }); final officesOnceProvider = FutureProvider>((ref) async { final client = ref.watch(supabaseClientProvider); final rows = await client.from('offices').select().order('name'); return (rows as List) .map((row) => Office.fromMap(row as Map)) .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( (ref) => const OfficeQuery(), ); final officesControllerProvider = Provider((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, ); } } final ticketsProvider = StreamProvider>((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 []); } final isGlobal = profile.role == 'admin' || profile.role == 'dispatcher' || profile.role == 'it_staff'; // Use stream for realtime updates, then apply pagination & search filters // client-side because `.range(...)` is not supported on the stream builder. final baseStream = client .from('tickets') .stream(primaryKey: ['id']) .map((rows) => rows.map(Ticket.fromMap).toList()); return baseStream.map((allTickets) { debugPrint('[ticketsProvider] stream event: ${allTickets.length} rows'); var list = allTickets; if (!isGlobal) { final officeIds = assignmentsAsync.valueOrNull ?.where((assignment) => assignment.userId == profile.id) .map((assignment) => assignment.officeId) .toSet() .toList() ?? []; if (officeIds.isEmpty) return []; final allowedOffices = officeIds.toSet(); list = list.where((t) => allowedOffices.contains(t.officeId)).toList(); } if (query.officeId != null) { list = list.where((t) => t.officeId == query.officeId).toList(); } if (query.status != null) { list = list.where((t) => t.status == query.status).toList(); } if (query.searchQuery.isNotEmpty) { final q = query.searchQuery.toLowerCase(); list = list .where( (t) => t.subject.toLowerCase().contains(q) || t.description.toLowerCase().contains(q), ) .toList(); } // Sort: newest first list.sort((a, b) => b.createdAt.compareTo(a.createdAt)); // Pagination final start = query.offset; final end = (start + query.limit).clamp(0, list.length); if (start >= list.length) return []; return list.sublist(start, end); }); }); /// Provider for ticket query parameters. final ticketsQueryProvider = StateProvider( (ref) => const TicketQuery(), ); final ticketMessagesProvider = StreamProvider.family, String>((ref, ticketId) { final client = ref.watch(supabaseClientProvider); return client .from('ticket_messages') .stream(primaryKey: ['id']) .eq('ticket_id', ticketId) .order('created_at', ascending: false) .map((rows) => rows.map(TicketMessage.fromMap).toList()); }); final ticketMessagesAllProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); return client .from('ticket_messages') .stream(primaryKey: ['id']) .order('created_at', ascending: false) .map((rows) => rows.map(TicketMessage.fromMap).toList()); }); final taskMessagesProvider = StreamProvider.family, String>( (ref, taskId) { final client = ref.watch(supabaseClientProvider); return client .from('ticket_messages') .stream(primaryKey: ['id']) .eq('task_id', taskId) .order('created_at', ascending: false) .map((rows) => rows.map(TicketMessage.fromMap).toList()); }, ); final ticketsControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); return TicketsController(client); }); class TicketsController { TicketsController(this._client); final SupabaseClient _client; Future 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 _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> _fetchRoleUserIds({ required List roles, required String? excludeUserId, }) async { try { final data = await _client .from('profiles') .select('id, role') .inFilter('role', roles); final rows = data as List; final ids = rows .map((row) => row['id'] as String?) .whereType() .where((id) => id.isNotEmpty && id != excludeUserId) .toList(); return ids; } catch (_) { return []; } } Future 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 sendTaskMessage({ required String taskId, required String? ticketId, required String content, }) async { final payload = { '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 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 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 { OfficesController(this._client); final SupabaseClient _client; Future 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 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 deleteOffice({required String id}) async { await _client.from('offices').delete().eq('id', id); } }