import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; 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 'package:flutter_dotenv/flutter_dotenv.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'; // Helper to insert activity log rows while sanitizing nulls and // avoiding exceptions from malformed payloads. Accepts either a Map // or a List. Future _insertActivityRows(dynamic client, dynamic rows) async { try { if (rows == null) return; if (rows is List) { final sanitized = rows .map((r) { if (r is Map) { final m = Map.from(r); m.removeWhere((k, v) => v == null); return m; } return null; }) .whereType>() .toList(); if (sanitized.isEmpty) return; await client.from('task_activity_logs').insert(sanitized); } else if (rows is Map) { final m = Map.from(rows); m.removeWhere((k, v) => v == null); await client.from('task_activity_logs').insert(m); } } catch (e) { // Log for debugging but don't rethrow to avoid breaking caller flows try { debugPrint('[insertActivityRows] insert failed: $e'); } catch (_) {} } } /// 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.taskNumber, 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 task number (partial match, case-insensitive). final String? taskNumber; /// Filter by date range. final DateTimeRange? dateRange; TaskQuery copyWith({ int? offset, int? limit, String? searchQuery, String? officeId, String? status, String? taskNumber, DateTimeRange? dateRange, }) { return TaskQuery( offset: offset ?? this.offset, limit: limit ?? this.limit, searchQuery: searchQuery ?? this.searchQuery, officeId: officeId ?? this.officeId, status: status ?? this.status, taskNumber: taskNumber ?? this.taskNumber, 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) || (t.taskNumber?.toLowerCase().contains(q) ?? false), ) .toList(); } if (query.taskNumber != null && query.taskNumber!.trim().isNotEmpty) { final tn = query.taskNumber!.toLowerCase(); list = list .where((t) => (t.taskNumber ?? '').toLowerCase().contains(tn)) .toList(); } // Sort by status groups then within-group ordering: // 1. queued – order by priority (desc), then queue_order (asc), then created_at // 2. in_progress – preserve recent order (created_at asc) // 3. completed – order by numeric task_number when available (asc) // 4. other statuses – fallback to queue_order then created_at int statusRank(String s) { switch (s) { case 'queued': return 0; case 'in_progress': return 1; case 'completed': return 2; default: return 3; } } int? parseTaskNumber(Task t) { final tn = t.taskNumber; if (tn == null) return null; final m = RegExp(r'\d+').firstMatch(tn); if (m == null) return null; return int.tryParse(m.group(0)!); } list.sort((a, b) { final ra = statusRank(a.status); final rb = statusRank(b.status); final rcmp = ra.compareTo(rb); if (rcmp != 0) return rcmp; // Same status: apply within-group ordering if (ra == 0) { // queued: higher priority first, then queue_order asc, then created_at final pcmp = b.priority.compareTo(a.priority); if (pcmp != 0) return pcmp; final aOrder = a.queueOrder ?? 0x7fffffff; final bOrder = b.queueOrder ?? 0x7fffffff; final qcmp = aOrder.compareTo(bOrder); if (qcmp != 0) return qcmp; return a.createdAt.compareTo(b.createdAt); } if (ra == 1) { // in_progress: keep older first return a.createdAt.compareTo(b.createdAt); } if (ra == 2) { // completed: prefer numeric task_number DESC when present final an = parseTaskNumber(a); final bn = parseTaskNumber(b); if (an != null && bn != null) return bn.compareTo(an); if (an != null) return -1; if (bn != null) return 1; return b.createdAt.compareTo(a.createdAt); } // fallback: queue_order then created_at 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); }); // Return the full filtered & sorted list to allow the UI layer to // perform pagination (desktop PaginatedDataTable expects the full // row count so it can render pagination controls reliably). The // Supabase stream currently delivers all rows and the provider // applies filtering/sorting; leaving pagination to the UI avoids // off-by-one issues where a full page of results would hide the // presence of a next page. return list; }); }); /// 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); // Supabase storage bucket for task action images. Ensure this bucket exists // with public read access. static const String _actionImageBucket = 'task_action_taken_images'; // _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; } // Prefer server RPC that atomically generates `task_number` and inserts // the task; fallback to client-side insert with retry on duplicate-key. String? taskId; String? assignedNumber; try { final rpcParams = Map.from(payload); // Retry RPC on duplicate-key (23505) errors which may occur // transiently due to concurrent inserts; prefer RPC always. const int rpcMaxAttempts = 3; Map? rpcRow; for (var attempt = 0; attempt < rpcMaxAttempts; attempt++) { try { final rpcRes = await _client .rpc('insert_task_with_number', rpcParams) .single(); if (rpcRes is Map) { rpcRow = Map.from(rpcRes); } else if (rpcRes is List && rpcRes.isNotEmpty && rpcRes.first is Map) { rpcRow = Map.from(rpcRes.first as Map); } break; } catch (err) { final msg = err.toString(); final isDuplicateKey = msg.contains('duplicate key value') || msg.contains('23505'); if (!isDuplicateKey || attempt == rpcMaxAttempts - 1) { rethrow; } await Future.delayed(Duration(milliseconds: 150 * (attempt + 1))); // retry } } if (rpcRow != null) { taskId = rpcRow['id'] as String?; assignedNumber = rpcRow['task_number'] as String?; } debugPrint( 'createTask via RPC assigned number=$assignedNumber id=$taskId', ); } catch (e) { // RPC not available or failed; fallback to client insert with retry const int maxAttempts = 3; Map? insertData; for (var attempt = 0; attempt < maxAttempts; attempt++) { try { insertData = await _client .from('tasks') .insert(payload) .select('id, task_number') .single(); break; } catch (err) { final msg = err.toString(); final isDuplicateKey = msg.contains('duplicate key value') || msg.contains('23505'); if (!isDuplicateKey || attempt == maxAttempts - 1) { rethrow; } await Future.delayed(Duration(milliseconds: 150 * (attempt + 1))); } } taskId = insertData == null ? null : insertData['id'] as String?; assignedNumber = insertData == null ? null : insertData['task_number'] as String?; debugPrint( 'createTask fallback assigned number=$assignedNumber id=$taskId', ); } if (taskId == null) return; try { await _insertActivityRows(_client, { '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 debugPrint('autoAssignTask failed for task=$taskId: $e\n$st'); } unawaited(_notifyCreated(taskId: taskId, actorId: actorId)); } /// Uploads an image for a task's action field and returns the public URL. /// /// [bytes] should contain the file data and [extension] the file extension /// (e.g. 'png' or 'jpg'). The image will be stored under a path that /// includes the task ID and a timestamp to avoid collisions. Returns `null` /// if the upload fails. Future uploadActionImage({ required String taskId, required Uint8List bytes, required String extension, }) async { final path = 'tasks/$taskId/${DateTime.now().millisecondsSinceEpoch}.$extension'; try { // debug: show upload path debugPrint('uploadActionImage uploading to path: $path'); // perform the upload and capture whatever the SDK returns (it varies by platform) final dynamic res; if (kIsWeb) { // on web, upload binary data res = await _client.storage .from(_actionImageBucket) .uploadBinary(path, bytes); } else { // write bytes to a simple temp file (no nested folders) final tmpDir = Directory.systemTemp; final localFile = File( '${tmpDir.path}/${DateTime.now().millisecondsSinceEpoch}.$extension', ); try { await localFile.create(); await localFile.writeAsBytes(bytes); } catch (e) { debugPrint('uploadActionImage failed writing temp file: $e'); return null; } res = await _client.storage .from(_actionImageBucket) .upload(path, localFile); try { await localFile.delete(); } catch (_) {} } debugPrint( 'uploadActionImage response type=${res.runtimeType} value=$res', ); // Some SDK methods return a simple String (path) on success, others // return a StorageResponse with an error field. Avoid calling .error on a // String to prevent NoSuchMethodError as seen in logs earlier. if (res is String) { // treat as success } else if (res is Map && res['error'] != null) { // older versions might return a plain map debugPrint('uploadActionImage upload error: ${res['error']}'); return null; } else if (res != null && res.error != null) { // StorageResponse case debugPrint('uploadActionImage upload error: ${res.error}'); return null; } } catch (e) { debugPrint('uploadActionImage failed upload: $e'); return null; } try { final urlRes = await _client.storage .from(_actionImageBucket) .getPublicUrl(path); // debug: log full response debugPrint('uploadActionImage getPublicUrl response: $urlRes'); String? url; if (urlRes is String) { url = urlRes; } else if (urlRes is Map && urlRes['data'] is String) { url = urlRes['data'] as String; } else if (urlRes != null) { try { url = urlRes.data as String?; } catch (_) { url = null; } } if (url != null && url.isNotEmpty) { // trim whitespace/newline which may be added by SDK or logging return url.trim(); } // fallback: construct URL manually using env variable final supabaseUrl = dotenv.env['SUPABASE_URL'] ?? ''; if (supabaseUrl.isEmpty) return null; return '$supabaseUrl/storage/v1/object/public/$_actionImageBucket/$path' .trim(); } catch (e) { debugPrint('uploadActionImage getPublicUrl error: $e'); return null; } } 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); // Send FCM pushes with meaningful text try { // resolve actor display name if possible 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 (_) {} } // fetch task_number and office (try embedding office.name); fallback to offices lookup String? taskNumber; String? officeId; String? officeName; try { final t = await _client .from('tasks') .select('task_number, office_id, offices(name)') .eq('id', taskId) .maybeSingle(); if (t != null) { if (t['task_number'] != null) { taskNumber = t['task_number'].toString(); } if (t['office_id'] != null) officeId = t['office_id'].toString(); final dynOffices = t['offices']; if (dynOffices != null) { if (dynOffices is List && dynOffices.isNotEmpty && dynOffices.first['name'] != null) { officeName = dynOffices.first['name'].toString(); } else if (dynOffices is Map && dynOffices['name'] != null) { officeName = dynOffices['name'].toString(); } } } } catch (_) {} if ((officeName == null || officeName.isEmpty) && officeId != null && officeId.isNotEmpty) { try { final o = await _client .from('offices') .select('name') .eq('id', officeId) .maybeSingle(); if (o != null && o['name'] != null) { officeName = o['name'].toString(); } } catch (_) {} } final title = 'New task'; final body = taskNumber != null ? (officeName != null ? '$actorName created task #$taskNumber in $officeName.' : '$actorName created task #$taskNumber.') : (officeName != null ? '$actorName created a new task in $officeName.' : '$actorName created a new task.'); final dataPayload = { 'type': 'created', 'task_number': ?taskNumber, 'office_id': ?officeId, 'office_name': ?officeName, }; await _client.functions.invoke( 'send_fcm', body: { 'user_ids': recipients, 'title': title, 'body': body, 'data': dataPayload, }, ); } catch (e) { // non-fatal: push failure should not break flow debugPrint('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; 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 several required fields try { final row = await _client .from('tasks') // include all columns that must be non-null/empty before completing .select( // signatories are not needed for validation; action_taken is still // required so we include it alongside the type/category fields. 'request_type, request_category, action_taken', ) .eq('id', taskId) .maybeSingle(); if (row is! Map) { throw Exception('Task not found'); } final rt = row['request_type']; final rc = row['request_category']; final action = row['action_taken']; final missing = []; if (rt == null || (rt is String && rt.trim().isEmpty)) { missing.add('request type'); } if (rc == null || (rc is String && rc.trim().isEmpty)) { missing.add('request category'); } // signatories are no longer required for completion; they can be // filled in later. we still require action taken to document what // was done. // if you want to enforce action taken you can uncomment this block, // but current business rule only mandates request metadata. we keep // action taken non-null for clarity. if (action == null || (action is String && action.trim().isEmpty)) { missing.add('action taken'); } if (missing.isNotEmpty) { throw Exception( 'The following fields must be set before completing a task: ${missing.join(', ')}.', ); } } catch (e) { // rethrow so callers can handle (UI will display message) 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 _insertActivityRows(_client, { 'task_id': taskId, 'actor_id': actorId, 'action_type': 'started', }); } else if (status == 'completed') { await _insertActivityRows(_client, { '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); } /// Update editable task fields such as title, description, office or linked ticket. Future updateTaskFields({ required String taskId, String? title, String? description, String? officeId, String? ticketId, }) async { final payload = {}; if (title != null) payload['title'] = title; if (description != null) payload['description'] = description; if (officeId != null) payload['office_id'] = officeId; if (ticketId != null) payload['ticket_id'] = ticketId; if (payload.isEmpty) return; await _client.from('tasks').update(payload).eq('id', taskId); // record an activity log for edit operations (best-effort) try { final actorId = _client.auth.currentUser?.id; await _insertActivityRows(_client, { 'task_id': taskId, 'actor_id': actorId, 'action_type': 'updated', 'meta': payload, }); } catch (_) {} } // 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 _insertActivityRows(_client, { '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 _insertActivityRows(_client, { '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 _insertActivityRows(_client, { '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', }); // send push for auto-assignment try { final actorName = 'Dispatcher'; // fetch task_number and office for nicer deep-linking when available String? taskNumber; String? officeId; String? officeName; try { final t = await _client .from('tasks') .select('task_number, office_id, offices(name)') .eq('id', taskId) .maybeSingle(); if (t != null) { if (t['task_number'] != null) { taskNumber = t['task_number'].toString(); } if (t['office_id'] != null) officeId = t['office_id'].toString(); final dynOffices = t['offices']; if (dynOffices != null) { if (dynOffices is List && dynOffices.isNotEmpty && dynOffices.first['name'] != null) { officeName = dynOffices.first['name'].toString(); } else if (dynOffices is Map && dynOffices['name'] != null) { officeName = dynOffices['name'].toString(); } } } } catch (_) {} if ((officeName == null || officeName.isEmpty) && officeId != null && officeId.isNotEmpty) { try { final o = await _client .from('offices') .select('name') .eq('id', officeId) .maybeSingle(); if (o != null && o['name'] != null) { officeName = o['name'].toString(); } } catch (_) {} } final dataPayload = { 'type': 'assignment', 'task_number': ?taskNumber, 'office_id': ?officeId, 'office_name': ?officeName, }; final title = 'Task assigned'; final body = taskNumber != null ? (officeName != null ? '$actorName assigned you task #$taskNumber in $officeName.' : '$actorName assigned you task #$taskNumber.') : (officeName != null ? '$actorName assigned you a task in $officeName.' : '$actorName assigned you a task.'); await _client.functions.invoke( 'send_fcm', body: { 'user_ids': [chosen.userId], 'title': title, 'body': body, 'data': dataPayload, }, ); } catch (e) { debugPrint('autoAssign push error: $e'); } } catch (_) {} } catch (e, st) { // Log error for visibility and record a failed auto-assign activity debugPrint('autoAssignTask error for task=$taskId: $e\n$st'); try { await _insertActivityRows(_client, { '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 _insertActivityRows(_client, 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 _insertActivityRows(_client, { '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); // send FCM pushes for explicit assignments 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 (_) {} } // fetch task_number and office (try embedding office.name); fallback to offices lookup String? taskNumber; String? officeId; String? officeName; try { final t = await _client .from('tasks') .select('task_number, office_id, offices(name)') .eq('id', taskId) .maybeSingle(); if (t != null) { if (t['task_number'] != null) { taskNumber = t['task_number'].toString(); } if (t['office_id'] != null) officeId = t['office_id'].toString(); final dynOffices = t['offices']; if (dynOffices != null) { if (dynOffices is List && dynOffices.isNotEmpty && dynOffices.first['name'] != null) { officeName = dynOffices.first['name'].toString(); } else if (dynOffices is Map && dynOffices['name'] != null) { officeName = dynOffices['name'].toString(); } } } } catch (_) {} if ((officeName == null || officeName.isEmpty) && officeId != null && officeId.isNotEmpty) { try { final o = await _client .from('offices') .select('name') .eq('id', officeId) .maybeSingle(); if (o != null && o['name'] != null) { officeName = o['name'].toString(); } } catch (_) {} } final dataPayload = { 'type': 'assignment', 'task_number': ?taskNumber, 'ticket_id': ?ticketId, 'office_id': ?officeId, 'office_name': ?officeName, }; final title = 'Task assigned'; final body = taskNumber != null ? (officeName != null ? '$actorName assigned you task #$taskNumber in $officeName.' : '$actorName assigned you task #$taskNumber.') : (officeName != null ? '$actorName assigned you a task in $officeName.' : '$actorName assigned you a task.'); await _client.functions.invoke( 'send_fcm', body: { 'user_ids': userIds, 'title': title, 'body': body, 'data': dataPayload, }, ); } catch (e) { debugPrint('notifyAssigned push error: $e'); } } 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); } }