import 'dart:async'; 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) { // 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); final SupabaseClient _client; Future createTask({ required String title, required String description, String? officeId, String? ticketId, }) 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; 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 as List; final ids = rows .map((row) => row['id'] as String?) .whereType() .where((id) => id.isNotEmpty && id != excludeUserId) .toList(); return ids; } catch (_) { return []; } } Future updateTaskStatus({ required String taskId, required String status, }) async { 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 } } // 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); } }