tasq/lib/providers/tickets_provider.dart

541 lines
16 KiB
Dart

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';
final officesProvider = StreamProvider<List<Office>>((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<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,
);
}
}
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 == 'dispatcher' ||
profile.role == 'it_staff';
// Use stream for realtime updates. Offload expensive client-side
// filtering/sorting/pagination to a background isolate via `compute`
// so UI navigation and builds remain smooth.
final baseStream = client.from('tickets').stream(primaryKey: ['id']);
return baseStream.asyncMap((rows) async {
// rows is List<dynamic> of maps coming from Supabase
final rowsList = (rows as List<dynamic>).cast<Map<String, dynamic>>();
// Prepare lightweight serializable args for background processing
final allowedOfficeIds =
assignmentsAsync.valueOrNull
?.where((assignment) => assignment.userId == profile.id)
.map((assignment) => assignment.officeId)
.toList() ??
<String>[];
final payload = <String, dynamic>{
'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 processed = await compute(_processTicketsInIsolate, payload);
// `processed` is List<Map<String,dynamic>> — convert to Ticket objects
final tickets = (processed as List<dynamic>)
.cast<Map<String, dynamic>>()
.map(Ticket.fromMap)
.toList();
debugPrint('[ticketsProvider] processed ${tickets.length} tickets');
return tickets;
});
});
// 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();
}
// Sort newest first. `created_at` may be ISO strings or timestamps;
// handle strings and numeric values.
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;
}
list.sort((a, b) => 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(),
);
final ticketMessagesProvider =
StreamProvider.family<List<TicketMessage>, 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<List<TicketMessage>>((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<List<TicketMessage>, 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<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);
}
}