tasq/lib/providers/tasks_provider.dart

1079 lines
34 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Map>.
Future<void> _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<String, dynamic>.from(r);
m.removeWhere((k, v) => v == null);
return m;
}
return null;
})
.whereType<Map<String, dynamic>>()
.toList();
if (sanitized.isEmpty) return;
await client.from('task_activity_logs').insert(sanitized);
} else if (rows is Map) {
final m = Map<String, dynamic>.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.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<List<Task>>((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 <Task>[]);
}
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<String> earlyAllowedTicketIds =
ticketsAsync.valueOrNull?.map((ticket) => ticket.id).toList() ??
<String>[];
List<String> earlyOfficeIds =
assignmentsAsync.valueOrNull
?.where((assignment) => assignment.userId == profile.id)
.map((assignment) => assignment.officeId)
.toSet()
.toList() ??
<String>[];
if (!isGlobal && earlyAllowedTicketIds.isEmpty && earlyOfficeIds.isEmpty) {
return Stream.value(const <Task>[]);
}
// 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() ??
<String>[];
final officeIds =
assignmentsAsync.valueOrNull
?.where((assignment) => assignment.userId == profile.id)
.map((assignment) => assignment.officeId)
.toSet()
.toList() ??
<String>[];
if (allowedTicketIds.isEmpty && officeIds.isEmpty) return <Task>[];
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();
}
// 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<TaskQuery>((ref) => const TaskQuery());
final taskAssignmentsProvider = StreamProvider<List<TaskAssignment>>((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<List<TaskActivityLog>, 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<TaskAssignmentsController>((
ref,
) {
final client = ref.watch(supabaseClientProvider);
return TaskAssignmentsController(client);
});
final tasksControllerProvider = Provider<TasksController>((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<void> 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 = <String, dynamic>{
'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 = {
'p_title': title,
'p_description': description,
'p_office_id': officeId,
'p_ticket_id': ticketId,
'p_request_type': requestType,
'p_request_type_other': requestTypeOther,
'p_request_category': requestCategory,
'p_creator_id': actorId,
};
// Retry RPC on duplicate-key (23505) errors which may occur
// transiently due to concurrent inserts; prefer RPC always.
const int rpcMaxAttempts = 3;
Map<String, dynamic>? 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<String, dynamic>.from(rpcRes);
} else if (rpcRes is List &&
rpcRes.isNotEmpty &&
rpcRes.first is Map) {
rpcRow = Map<String, dynamic>.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?;
}
// ignore: avoid_print
print('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<String, dynamic>? 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?;
// ignore: avoid_print
print('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
// ignore: avoid_print
print('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<String?> uploadActionImage({
required String taskId,
required Uint8List bytes,
required String extension,
}) async {
final path =
'tasks/$taskId/${DateTime.now().millisecondsSinceEpoch}.$extension';
try {
// debug: show upload path
// ignore: avoid_print
print('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) {
// ignore: avoid_print
print('uploadActionImage failed writing temp file: $e');
return null;
}
res = await _client.storage
.from(_actionImageBucket)
.upload(path, localFile);
try {
await localFile.delete();
} catch (_) {}
}
// debug: inspect the response object/type
// ignore: avoid_print
print('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
// ignore: avoid_print
print('uploadActionImage upload error: ${res['error']}');
return null;
} else if (res != null && res.error != null) {
// StorageResponse case
// ignore: avoid_print
print('uploadActionImage upload error: ${res.error}');
return null;
}
} catch (e) {
// ignore: avoid_print
print('uploadActionImage failed upload: $e');
return null;
}
try {
final urlRes = await _client.storage
.from(_actionImageBucket)
.getPublicUrl(path);
// debug: log full response
// ignore: avoid_print
print('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) {
// ignore: avoid_print
print('uploadActionImage getPublicUrl error: $e');
return null;
}
}
Future<void> _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<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;
final ids = rows
.map((row) => row['id'] as String?)
.whereType<String>()
.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<void> 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<String, dynamic>) {
throw Exception('Task not found');
}
final rt = row['request_type'];
final rc = row['request_category'];
final action = row['action_taken'];
final missing = <String>[];
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<void> updateTask({
required String taskId,
String? requestType,
String? requestTypeOther,
String? requestCategory,
String? status,
String? requestedBy,
String? notedBy,
String? receivedBy,
String? actionTaken,
}) async {
final payload = <String, dynamic>{};
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<void> updateTaskFields({
required String taskId,
String? title,
String? description,
String? officeId,
String? ticketId,
}) async {
final payload = <String, dynamic>{};
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<void> _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<dynamic>? ?? [];
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<dynamic>? ??
[];
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<dynamic>? ??
[];
final Map<String, DateTime> 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<dynamic>? ??
[];
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<dynamic>? ??
[];
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',
});
} 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 _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<AutoAssignCandidate> candidates) {
if (candidates.isEmpty) return null;
final list = List<AutoAssignCandidate>.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<void> replaceAssignments({
required String taskId,
required String? ticketId,
required List<String> newUserIds,
required List<String> 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<void> _notifyAssigned({
required String taskId,
required String? ticketId,
required List<String> 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<void> removeAssignment({
required String taskId,
required String userId,
}) async {
await _client
.from('task_assignments')
.delete()
.eq('task_id', taskId)
.eq('user_id', userId);
}
}