927 lines
28 KiB
Dart
927 lines
28 KiB
Dart
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';
|
|
|
|
/// 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: 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 <Task>[];
|
|
return list.sublist(start, end);
|
|
});
|
|
});
|
|
|
|
/// 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 _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));
|
|
}
|
|
|
|
/// 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
|
|
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<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);
|
|
}
|
|
|
|
// 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 {
|
|
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 _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<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);
|
|
}
|
|
}
|