Auto Task Assignment
This commit is contained in:
@@ -3,12 +3,14 @@ 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 {
|
||||
@@ -179,6 +181,18 @@ final taskAssignmentsProvider = StreamProvider<List<TaskAssignment>>((ref) {
|
||||
.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,
|
||||
) {
|
||||
@@ -196,30 +210,48 @@ class TasksController {
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
Future<void> updateTaskStatus({
|
||||
required String taskId,
|
||||
required String status,
|
||||
}) async {
|
||||
await _client.from('tasks').update({'status': status}).eq('id', taskId);
|
||||
}
|
||||
|
||||
Future<void> createTask({
|
||||
required String title,
|
||||
required String description,
|
||||
required String officeId,
|
||||
String? officeId,
|
||||
String? ticketId,
|
||||
}) 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;
|
||||
|
||||
final data = await _client
|
||||
.from('tasks')
|
||||
.insert({
|
||||
'title': title,
|
||||
'description': description,
|
||||
'office_id': officeId,
|
||||
})
|
||||
.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));
|
||||
}
|
||||
|
||||
@@ -269,6 +301,238 @@ class TasksController {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> 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<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 _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<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 _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<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 {
|
||||
@@ -292,6 +556,25 @@ class TaskAssignmentsController {
|
||||
.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) {
|
||||
@@ -300,6 +583,19 @@ class TaskAssignmentsController {
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user