tasq/lib/providers/tasks_provider.dart

1463 lines
46 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';
// 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.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<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']);
return baseStream.asyncMap((rows) async {
final rowsList = (rows as List<dynamic>).cast<Map<String, dynamic>>();
final allowedTicketIds =
ticketsAsync.valueOrNull?.map((ticket) => ticket.id).toList() ??
<String>[];
final allowedOfficeIds =
assignmentsAsync.valueOrNull
?.where((assignment) => assignment.userId == profile.id)
.map((assignment) => assignment.officeId)
.toList() ??
<String>[];
final payload = <String, dynamic>{
'rows': rowsList,
'isGlobal': isGlobal,
'allowedTicketIds': allowedTicketIds,
'allowedOfficeIds': allowedOfficeIds,
'officeId': query.officeId,
'status': query.status,
'searchQuery': query.searchQuery,
'taskNumber': query.taskNumber,
'dateStart': query.dateRange?.start.millisecondsSinceEpoch,
'dateEnd': query.dateRange?.end.millisecondsSinceEpoch,
};
final processed = await compute(_processTasksInIsolate, payload);
final tasks = (processed as List<dynamic>)
.cast<Map<String, dynamic>>()
.map(Task.fromMap)
.toList();
debugPrint('[tasksProvider] processed ${tasks.length} tasks');
return tasks;
});
});
// Runs inside a background isolate to filter/sort tasks represented as
// plain maps. Returns a List<Map<String,dynamic>> suitable for
// reconstruction with `Task.fromMap` on the main isolate.
List<Map<String, dynamic>> _processTasksInIsolate(
Map<String, dynamic> payload,
) {
var list = List<Map<String, dynamic>>.from(
(payload['rows'] as List).cast<Map<String, dynamic>>(),
);
final isGlobal = payload['isGlobal'] as bool? ?? false;
final allowedTicketIds =
(payload['allowedTicketIds'] as List?)?.cast<String>().toSet() ??
<String>{};
final allowedOfficeIds =
(payload['allowedOfficeIds'] as List?)?.cast<String>().toSet() ??
<String>{};
if (!isGlobal) {
if (allowedTicketIds.isEmpty && allowedOfficeIds.isEmpty) {
return <Map<String, dynamic>>[];
}
list = list.where((t) {
final tid = t['ticket_id'] as String?;
final oid = t['office_id'] as String?;
return (tid != null && allowedTicketIds.contains(tid)) ||
(oid != null && allowedOfficeIds.contains(oid));
}).toList();
}
final officeId = payload['officeId'] as String?;
if (officeId != null) {
list = list.where((t) => t['office_id'] == officeId).toList();
}
final status = payload['status'] as String?;
if (status != null) {
list = list.where((t) => t['status'] == status).toList();
}
final searchQuery = (payload['searchQuery'] as String?) ?? '';
if (searchQuery.isNotEmpty) {
final q = searchQuery.toLowerCase();
list = list.where((t) {
final title = (t['title'] as String?)?.toLowerCase() ?? '';
final desc = (t['description'] as String?)?.toLowerCase() ?? '';
final tn = (t['task_number'] as String?)?.toLowerCase() ?? '';
return title.contains(q) || desc.contains(q) || tn.contains(q);
}).toList();
}
final taskNumberFilter = (payload['taskNumber'] as String?)?.trim();
if (taskNumberFilter != null && taskNumberFilter.isNotEmpty) {
final tnLow = taskNumberFilter.toLowerCase();
list = list
.where(
(t) => ((t['task_number'] as String?) ?? '').toLowerCase().contains(
tnLow,
),
)
.toList();
}
int statusRank(String s) {
switch (s) {
case 'queued':
return 0;
case 'in_progress':
return 1;
case 'completed':
return 2;
case 'cancelled':
return 3;
default:
return 4;
}
}
int? parseTaskNumberFromString(String? tn) {
if (tn == null) return null;
final m = RegExp(r'\d+').firstMatch(tn);
if (m == null) return null;
return int.tryParse(m.group(0)!);
}
int parseCreatedAt(Map<String, dynamic> m) {
final v = m['created_at'];
if (v == null) return 0;
if (v is int) return v;
if (v is double) return v.toInt();
if (v is String) {
try {
return DateTime.parse(v).millisecondsSinceEpoch;
} catch (_) {
return 0;
}
}
return 0;
}
list.sort((a, b) {
final ra = statusRank((a['status'] as String?) ?? '');
final rb = statusRank((b['status'] as String?) ?? '');
final rcmp = ra.compareTo(rb);
if (rcmp != 0) return rcmp;
if (ra == 0) {
// queued: higher priority first, then queue_order asc, then created_at
final pa = (a['priority'] as num?)?.toInt() ?? 1;
final pb = (b['priority'] as num?)?.toInt() ?? 1;
final pcmp = pb.compareTo(pa);
if (pcmp != 0) return pcmp;
final aOrder = (a['queue_order'] as int?) ?? 0x7fffffff;
final bOrder = (b['queue_order'] as int?) ?? 0x7fffffff;
final qcmp = aOrder.compareTo(bOrder);
if (qcmp != 0) return qcmp;
return parseCreatedAt(a).compareTo(parseCreatedAt(b));
}
if (ra == 1) {
return parseCreatedAt(a).compareTo(parseCreatedAt(b));
}
if (ra == 2) {
final an = parseTaskNumberFromString(a['task_number'] as String?);
final bn = parseTaskNumberFromString(b['task_number'] as String?);
if (an != null && bn != null) return bn.compareTo(an);
if (an != null) return -1;
if (bn != null) return 1;
return parseCreatedAt(b).compareTo(parseCreatedAt(a));
}
final aOrder = (a['queue_order'] as int?) ?? 0x7fffffff;
final bOrder = (b['queue_order'] as int?) ?? 0x7fffffff;
final cmp = aOrder.compareTo(bOrder);
if (cmp != 0) return cmp;
return parseCreatedAt(a).compareTo(parseCreatedAt(b));
});
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 = Map<String, dynamic>.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<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?;
}
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<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?;
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<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
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<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);
// 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 = <String, dynamic>{
'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<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,
String? reason,
}) async {
if (status == 'cancelled') {
if (reason == null || reason.trim().isEmpty) {
throw Exception('Cancellation requires a reason.');
}
}
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;
}
}
// persist status and cancellation reason (when provided)
final payload = <String, dynamic>{'status': status};
if (status == 'cancelled') {
payload['cancellation_reason'] = reason;
}
await _client.from('tasks').update(payload).eq('id', taskId);
// if cancelled, also set cancelled_at timestamp
if (status == 'cancelled') {
try {
final cancelledAt = AppTime.now().toIso8601String();
await _client
.from('tasks')
.update({'cancelled_at': cancelledAt})
.eq('id', taskId);
} catch (_) {}
}
// 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',
});
} else if (status == 'cancelled') {
await _insertActivityRows(_client, {
'task_id': taskId,
'actor_id': actorId,
'action_type': 'cancelled',
'meta': {'reason': reason},
});
}
} 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);
// Record activity logs for any metadata/signatory fields that were changed
try {
final actorId = _client.auth.currentUser?.id;
final List<Map<String, dynamic>> logRows = [];
if (requestType != null) {
logRows.add({
'task_id': taskId,
'actor_id': actorId,
'action_type': 'filled_request_type',
'meta': {'value': requestType},
});
}
if (requestCategory != null) {
logRows.add({
'task_id': taskId,
'actor_id': actorId,
'action_type': 'filled_request_category',
'meta': {'value': requestCategory},
});
}
if (requestedBy != null) {
logRows.add({
'task_id': taskId,
'actor_id': actorId,
'action_type': 'filled_requested_by',
'meta': {'value': requestedBy},
});
}
if (notedBy != null) {
logRows.add({
'task_id': taskId,
'actor_id': actorId,
'action_type': 'filled_noted_by',
'meta': {'value': notedBy},
});
}
if (receivedBy != null) {
logRows.add({
'task_id': taskId,
'actor_id': actorId,
'action_type': 'filled_received_by',
'meta': {'value': receivedBy},
});
}
if (actionTaken != null) {
logRows.add({
'task_id': taskId,
'actor_id': actorId,
'action_type': 'filled_action_taken',
'meta': {'value': actionTaken},
});
}
if (logRows.isNotEmpty) {
await _insertActivityRows(_client, logRows);
}
} catch (_) {}
}
/// 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',
});
// 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 = <String, dynamic>{
'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<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);
// 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 = <String, dynamic>{
'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<void> removeAssignment({
required String taskId,
required String userId,
}) async {
await _client
.from('task_assignments')
.delete()
.eq('task_id', taskId)
.eq('user_id', userId);
}
}