tasq/lib/providers/tasks_provider.dart

638 lines
19 KiB
Dart

import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/task.dart';
import '../models/task_activity_log.dart';
import 'package:flutter/material.dart';
import '../models/task_assignment.dart';
import 'profile_provider.dart';
import 'supabase_provider.dart';
import 'tickets_provider.dart';
import 'user_offices_provider.dart';
import '../utils/app_time.dart';
/// Task query parameters for server-side pagination and filtering.
class TaskQuery {
/// Creates task query parameters.
const TaskQuery({
this.offset = 0,
this.limit = 50,
this.searchQuery = '',
this.officeId,
this.status,
this.assigneeId,
this.dateRange,
});
/// Offset for pagination.
final int offset;
/// Number of items per page (default: 50).
final int limit;
/// Full text search query.
final String searchQuery;
/// Filter by office ID.
final String? officeId;
/// Filter by status.
final String? status;
/// Filter by assignee ID.
final String? assigneeId;
/// Filter by date range.
/// Filter by date range.
final DateTimeRange? dateRange;
TaskQuery copyWith({
int? offset,
int? limit,
String? searchQuery,
String? officeId,
String? status,
String? assigneeId,
DateTimeRange? dateRange,
}) {
return TaskQuery(
offset: offset ?? this.offset,
limit: limit ?? this.limit,
searchQuery: searchQuery ?? this.searchQuery,
officeId: officeId ?? this.officeId,
status: status ?? this.status,
assigneeId: assigneeId ?? this.assigneeId,
dateRange: dateRange ?? this.dateRange,
);
}
}
final tasksProvider = StreamProvider<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) {
// 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),
)
.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);
final SupabaseClient _client;
Future<void> createTask({
required String title,
required String description,
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(payload)
.select('id')
.single();
final taskId = data['id'] as String?;
if (taskId == null) return;
// Activity log: created
try {
await _client.from('task_activity_logs').insert({
'task_id': taskId,
'actor_id': actorId,
'action_type': 'created',
});
} catch (_) {
// non-fatal
}
// Auto-assignment should run once on creation (best-effort).
try {
await _autoAssignTask(taskId: taskId, officeId: officeId ?? '');
} catch (e, st) {
// keep creation successful but surface the error in logs for debugging
// ignore: avoid_print
print('autoAssignTask failed for task=$taskId: $e\n$st');
}
unawaited(_notifyCreated(taskId: taskId, actorId: actorId));
}
Future<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 as List<dynamic>;
final ids = rows
.map((row) => row['id'] as String?)
.whereType<String>()
.where((id) => id.isNotEmpty && id != excludeUserId)
.toList();
return ids;
} catch (_) {
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 {
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);
}
}