import 'dart:async'; import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../models/task.dart'; import '../models/task_activity_log.dart'; import 'package:flutter/material.dart'; import '../models/task_assignment.dart'; import 'profile_provider.dart'; import 'supabase_provider.dart'; import 'tickets_provider.dart'; import 'user_offices_provider.dart'; import '../utils/app_time.dart'; /// Task query parameters for server-side pagination and filtering. class TaskQuery { /// Creates task query parameters. const TaskQuery({ this.offset = 0, this.limit = 50, this.searchQuery = '', this.officeId, this.status, this.assigneeId, 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 assignee ID. final String? assigneeId; /// Filter by date range. /// Filter by date range. final DateTimeRange? dateRange; TaskQuery copyWith({ int? offset, int? limit, String? searchQuery, String? officeId, String? status, String? assigneeId, DateTimeRange? dateRange, }) { return TaskQuery( offset: offset ?? this.offset, limit: limit ?? this.limit, searchQuery: searchQuery ?? this.searchQuery, officeId: officeId ?? this.officeId, status: status ?? this.status, assigneeId: assigneeId ?? this.assigneeId, dateRange: dateRange ?? this.dateRange, ); } } final tasksProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); final profileAsync = ref.watch(currentProfileProvider); final ticketsAsync = ref.watch(ticketsProvider); final assignmentsAsync = ref.watch(userOfficesProvider); final query = ref.watch(tasksQueryProvider); final profile = profileAsync.valueOrNull; if (profile == null) { return Stream.value(const []); } final isGlobal = profile.role == 'admin' || profile.role == 'dispatcher' || profile.role == 'it_staff'; // For RBAC early-exit: if the user has no accessible tickets/offices, // avoid subscribing to the full tasks stream. List earlyAllowedTicketIds = ticketsAsync.valueOrNull?.map((ticket) => ticket.id).toList() ?? []; List earlyOfficeIds = assignmentsAsync.valueOrNull ?.where((assignment) => assignment.userId == profile.id) .map((assignment) => assignment.officeId) .toSet() .toList() ?? []; if (!isGlobal && earlyAllowedTicketIds.isEmpty && earlyOfficeIds.isEmpty) { return Stream.value(const []); } // NOTE: Supabase stream builder does not support `.range(...)` — // apply pagination and remaining filters client-side after mapping. final baseStream = client .from('tasks') .stream(primaryKey: ['id']) .map((rows) => rows.map(Task.fromMap).toList()); return baseStream.map((allTasks) { debugPrint('[tasksProvider] stream event: ${allTasks.length} rows'); // RBAC (server-side filtering isn't possible via `.range` on stream builder, // so enforce allowed IDs here). var list = allTasks; if (!isGlobal) { final allowedTicketIds = ticketsAsync.valueOrNull?.map((ticket) => ticket.id).toList() ?? []; final officeIds = assignmentsAsync.valueOrNull ?.where((assignment) => assignment.userId == profile.id) .map((assignment) => assignment.officeId) .toSet() .toList() ?? []; if (allowedTicketIds.isEmpty && officeIds.isEmpty) return []; final allowedTickets = allowedTicketIds.toSet(); final allowedOffices = officeIds.toSet(); list = list .where( (t) => (t.ticketId != null && allowedTickets.contains(t.ticketId)) || (t.officeId != null && allowedOffices.contains(t.officeId)), ) .toList(); } // Query filters (apply client-side) 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.title.toLowerCase().contains(q) || t.description.toLowerCase().contains(q), ) .toList(); } // Sort: queue_order ASC, then created_at ASC list.sort((a, b) { final aOrder = a.queueOrder ?? 0x7fffffff; final bOrder = b.queueOrder ?? 0x7fffffff; final cmp = aOrder.compareTo(bOrder); if (cmp != 0) return cmp; return a.createdAt.compareTo(b.createdAt); }); // Pagination (server-side semantics emulated client-side) 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 task query parameters. final tasksQueryProvider = StateProvider((ref) => const TaskQuery()); final taskAssignmentsProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); return client .from('task_assignments') .stream(primaryKey: ['task_id', 'user_id']) .map((rows) => rows.map(TaskAssignment.fromMap).toList()); }); /// Stream of activity logs for a single task. final taskActivityLogsProvider = StreamProvider.family, String>((ref, taskId) { final client = ref.watch(supabaseClientProvider); return client .from('task_activity_logs') .stream(primaryKey: ['id']) .eq('task_id', taskId) .order('created_at', ascending: false) .map((rows) => rows.map((r) => TaskActivityLog.fromMap(r)).toList()); }); final taskAssignmentsControllerProvider = Provider(( ref, ) { final client = ref.watch(supabaseClientProvider); return TaskAssignmentsController(client); }); final tasksControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); return TasksController(client); }); class TasksController { TasksController(this._client); // _client is declared dynamic allowing test doubles that mimic only the // subset of methods used by this class. In production it will be a // SupabaseClient instance. final dynamic _client; Future createTask({ required String title, required String description, String? officeId, String? ticketId, // optional request metadata when creating a task String? requestType, String? requestTypeOther, String? requestCategory, }) async { final actorId = _client.auth.currentUser?.id; final payload = { 'title': title, 'description': description, }; if (officeId != null) { payload['office_id'] = officeId; } if (ticketId != null) { payload['ticket_id'] = ticketId; } if (requestType != null) { payload['request_type'] = requestType; } if (requestTypeOther != null) { payload['request_type_other'] = requestTypeOther; } if (requestCategory != null) { payload['request_category'] = requestCategory; } final data = await _client .from('tasks') .insert(payload) .select('id') .single(); final taskId = data['id'] as String?; if (taskId == null) return; // Activity log: created try { await _client.from('task_activity_logs').insert({ 'task_id': taskId, 'actor_id': actorId, 'action_type': 'created', }); } catch (_) { // non-fatal } // Auto-assignment should run once on creation (best-effort). try { await _autoAssignTask(taskId: taskId, officeId: officeId ?? ''); } catch (e, st) { // keep creation successful but surface the error in logs for debugging // ignore: avoid_print print('autoAssignTask failed for task=$taskId: $e\n$st'); } unawaited(_notifyCreated(taskId: taskId, actorId: actorId)); } Future _notifyCreated({ required String taskId, 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, 'task_id': taskId, 'type': 'created', }, ) .toList(); await _client.from('notifications').insert(rows); } 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; final ids = rows .map((row) => row['id'] as String?) .whereType() .where((id) => id.isNotEmpty && id != excludeUserId) .toList(); return ids; } catch (_) { return []; } } /// Update only the status of a task. /// /// Before marking a task as **completed** we enforce that the /// request type/category metadata have been provided. This protects the /// business rule that details must be specified before closing. Future updateTaskStatus({ required String taskId, required String status, }) async { if (status == 'completed') { // fetch current metadata to validate try { final row = await _client .from('tasks') .select('request_type, request_category') .eq('id', taskId) .maybeSingle(); final rt = row is Map ? row['request_type'] : null; final rc = row is Map ? row['request_category'] : null; if (rt == null || rc == null) { throw Exception( 'Request type and category must be set before completing a task.', ); } } catch (e) { // rethrow so callers can handle rethrow; } } await _client.from('tasks').update({'status': status}).eq('id', taskId); // Log important status transitions try { final actorId = _client.auth.currentUser?.id; if (status == 'in_progress') { await _client.from('task_activity_logs').insert({ 'task_id': taskId, 'actor_id': actorId, 'action_type': 'started', }); } else if (status == 'completed') { await _client.from('task_activity_logs').insert({ 'task_id': taskId, 'actor_id': actorId, 'action_type': 'completed', }); } } catch (_) { // ignore logging failures } } /// Update arbitrary fields on a task row. /// /// Primarily used to set request metadata after creation or during /// status transitions. Future updateTask({ required String taskId, String? requestType, String? requestTypeOther, String? requestCategory, String? status, String? requestedBy, String? notedBy, String? receivedBy, String? actionTaken, }) async { final payload = {}; if (requestType != null) { payload['request_type'] = requestType; } if (requestTypeOther != null) { payload['request_type_other'] = requestTypeOther; } if (requestCategory != null) { payload['request_category'] = requestCategory; } if (requestedBy != null) { payload['requested_by'] = requestedBy; } if (notedBy != null) { payload['noted_by'] = notedBy; } // `performed_by` is derived from task assignments; we don't persist it here. if (receivedBy != null) { payload['received_by'] = receivedBy; } if (actionTaken != null) { try { payload['action_taken'] = jsonDecode(actionTaken); } catch (_) { // fallback: store raw string payload['action_taken'] = actionTaken; } } if (status != null) { payload['status'] = status; } if (payload.isEmpty) { return; } await _client.from('tasks').update(payload).eq('id', taskId); } // Auto-assignment logic executed once on creation. Future _autoAssignTask({ required String taskId, required String officeId, }) async { if (officeId.isEmpty) return; final now = AppTime.now(); final startOfDay = DateTime(now.year, now.month, now.day); final nextDay = startOfDay.add(const Duration(days: 1)); try { // 1) Find teams covering the office final teamsRows = (await _client.from('teams').select()) as List? ?? []; final teamIds = teamsRows .where((r) => (r['office_ids'] as List?)?.contains(officeId) == true) .map((r) => r['id'] as String) .toSet() .toList(); if (teamIds.isEmpty) return; // 2) Get members of those teams final memberRows = (await _client .from('team_members') .select('user_id') .inFilter('team_id', teamIds)) as List? ?? []; final candidateIds = memberRows .map((r) => r['user_id'] as String) .toSet() .toList(); if (candidateIds.isEmpty) return; // 3) Filter by "On Duty" (have a check-in record for today) final dsRows = (await _client .from('duty_schedules') .select('user_id, check_in_at') .inFilter('user_id', candidateIds)) as List? ?? []; final Map onDuty = {}; for (final r in dsRows) { final userId = r['user_id'] as String?; final checkIn = r['check_in_at'] as String?; if (userId == null || checkIn == null) continue; final dt = DateTime.tryParse(checkIn); if (dt == null) continue; if (dt.isAfter(startOfDay.subtract(const Duration(seconds: 1))) && dt.isBefore(nextDay.add(const Duration(seconds: 1)))) { onDuty[userId] = dt; } } if (onDuty.isEmpty) { // record a failed auto-assign attempt for observability try { await _client.from('task_activity_logs').insert({ 'task_id': taskId, 'actor_id': null, 'action_type': 'auto_assign_failed', 'meta': {'reason': 'no_on_duty_candidates'}, }); } catch (_) {} return; } // 4) For each on-duty user compute completed_tasks_count for today final List<_Candidate> candidates = []; for (final userId in onDuty.keys) { // get task ids assigned to user final taRows = (await _client .from('task_assignments') .select('task_id') .eq('user_id', userId)) as List? ?? []; final assignedTaskIds = taRows .map((r) => r['task_id'] as String) .toList(); int completedCount = 0; if (assignedTaskIds.isNotEmpty) { final tasksRows = (await _client .from('tasks') .select('id') .inFilter('id', assignedTaskIds) .gte('completed_at', startOfDay.toIso8601String()) .lt('completed_at', nextDay.toIso8601String())) as List? ?? []; completedCount = tasksRows.length; } candidates.add( _Candidate( userId: userId, checkInAt: onDuty[userId]!, completedToday: completedCount, ), ); } if (candidates.isEmpty) { try { await _client.from('task_activity_logs').insert({ 'task_id': taskId, 'actor_id': null, 'action_type': 'auto_assign_failed', 'meta': {'reason': 'no_eligible_candidates'}, }); } catch (_) {} return; } // 5) Sort: latest check-in first (desc), then lowest completed_today candidates.sort((a, b) { final c = b.checkInAt.compareTo(a.checkInAt); if (c != 0) return c; return a.completedToday.compareTo(b.completedToday); }); final chosen = candidates.first; // 6) Insert assignment + activity log + notification await _client.from('task_assignments').insert({ 'task_id': taskId, 'user_id': chosen.userId, }); try { await _client.from('task_activity_logs').insert({ 'task_id': taskId, 'actor_id': null, 'action_type': 'assigned', 'meta': {'auto': true, 'user_id': chosen.userId}, }); } catch (_) {} try { await _client.from('notifications').insert({ 'user_id': chosen.userId, 'actor_id': null, 'task_id': taskId, 'type': 'assignment', }); } catch (_) {} } catch (e, st) { // Log error for visibility and record a failed auto-assign activity // ignore: avoid_print print('autoAssignTask error for task=$taskId: $e\n$st'); try { await _client.from('task_activity_logs').insert({ 'task_id': taskId, 'actor_id': null, 'action_type': 'auto_assign_failed', 'meta': {'reason': 'exception', 'error': e.toString()}, }); } catch (_) {} return; } } } /// Public DTO used by unit tests to validate selection logic. class AutoAssignCandidate { AutoAssignCandidate({ required this.userId, required this.checkInAt, required this.completedToday, }); final String userId; final DateTime checkInAt; final int completedToday; } /// Choose the best candidate according to auto-assignment rules: /// - latest check-in first (late-comer priority) /// - tie-breaker: lowest completedTasks (today) /// Returns the chosen userId or null when candidates is empty. String? chooseAutoAssignCandidate(List candidates) { if (candidates.isEmpty) return null; final list = List.from(candidates); list.sort((a, b) { final c = b.checkInAt.compareTo(a.checkInAt); if (c != 0) return c; return a.completedToday.compareTo(b.completedToday); }); return list.first.userId; } class _Candidate { _Candidate({ required this.userId, required this.checkInAt, required this.completedToday, }); final String userId; final DateTime checkInAt; final int completedToday; } class TaskAssignmentsController { TaskAssignmentsController(this._client); final SupabaseClient _client; Future replaceAssignments({ required String taskId, required String? ticketId, required List newUserIds, required List currentUserIds, }) async { final nextIds = newUserIds.toSet(); final currentIds = currentUserIds.toSet(); final toAdd = nextIds.difference(currentIds).toList(); final toRemove = currentIds.difference(nextIds).toList(); if (toAdd.isNotEmpty) { final rows = toAdd .map((userId) => {'task_id': taskId, 'user_id': userId}) .toList(); await _client.from('task_assignments').insert(rows); // Insert activity log(s) for assignment(s). try { final actorId = _client.auth.currentUser?.id; final logRows = toAdd .map( (userId) => { 'task_id': taskId, 'actor_id': actorId, 'action_type': 'assigned', 'meta': {'user_id': userId}, }, ) .toList(); await _client.from('task_activity_logs').insert(logRows); } catch (_) { // non-fatal } await _notifyAssigned(taskId: taskId, ticketId: ticketId, userIds: toAdd); } if (toRemove.isNotEmpty) { await _client .from('task_assignments') .delete() .eq('task_id', taskId) .inFilter('user_id', toRemove); // Record a reassignment event (who removed -> who added) try { final actorId = _client.auth.currentUser?.id; await _client.from('task_activity_logs').insert({ 'task_id': taskId, 'actor_id': actorId, 'action_type': 'reassigned', 'meta': {'from': toRemove, 'to': toAdd}, }); } catch (_) { // non-fatal } } } Future _notifyAssigned({ required String taskId, required String? ticketId, required List userIds, }) async { if (userIds.isEmpty) return; try { final actorId = _client.auth.currentUser?.id; final rows = userIds .map( (userId) => { 'user_id': userId, 'actor_id': actorId, 'task_id': taskId, 'ticket_id': ticketId, 'type': 'assignment', }, ) .toList(); await _client.from('notifications').insert(rows); } catch (_) { return; } } Future removeAssignment({ required String taskId, required String userId, }) async { await _client .from('task_assignments') .delete() .eq('task_id', taskId) .eq('user_id', userId); } }