tasq/lib/providers/tasks_provider.dart

1668 lines
53 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 'dart:typed_data';
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 'stream_recovery.dart';
import 'realtime_controller.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,
);
}
}
/// Builds the isolate payload from a list of [Task] objects and the current
/// query/access context. Extracted so the initial REST seed and the realtime
/// stream listener can share the same logic without duplication.
Map<String, dynamic> _buildTaskPayload({
required List<Task> tasks,
required bool isGlobal,
required List<String> allowedTicketIds,
required List<String> allowedOfficeIds,
required TaskQuery query,
}) {
final rowsList = tasks
.map(
(task) => <String, dynamic>{
'id': task.id,
'task_number': task.taskNumber,
'office_id': task.officeId,
'ticket_id': task.ticketId,
'title': task.title,
'description': task.description,
'status': task.status,
'priority': task.priority,
'creator_id': task.creatorId,
'created_at': task.createdAt.toIso8601String(),
'started_at': task.startedAt?.toIso8601String(),
'completed_at': task.completedAt?.toIso8601String(),
'requested_by': task.requestedBy,
'noted_by': task.notedBy,
'received_by': task.receivedBy,
'queue_order': task.queueOrder,
'request_type': task.requestType,
'request_type_other': task.requestTypeOther,
'request_category': task.requestCategory,
'action_taken': task.actionTaken,
'cancellation_reason': task.cancellationReason,
'cancelled_at': task.cancelledAt?.toIso8601String(),
},
)
.toList();
return {
'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 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';
final allowedTicketIds =
ticketsAsync.valueOrNull?.map((t) => t.id).toList() ?? <String>[];
final allowedOfficeIds =
assignmentsAsync.valueOrNull
?.where((a) => a.userId == profile.id)
.map((a) => a.officeId)
.toSet()
.toList() ??
<String>[];
// For non-global users with no assigned offices/tickets, skip subscribing.
if (!isGlobal && allowedTicketIds.isEmpty && allowedOfficeIds.isEmpty) {
return Stream.value(const <Task>[]);
}
// Wrap realtime stream with recovery logic
final wrapper = StreamRecoveryWrapper<Task>(
stream: client.from('tasks').stream(primaryKey: ['id']),
onPollData: () async {
final data = await client.from('tasks').select();
return data.cast<Map<String, dynamic>>().map(Task.fromMap).toList();
},
fromMap: Task.fromMap,
channelName: 'tasks',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
var lastResultHash = '';
Timer? debounceTimer;
// broadcast() so Riverpod and any other listener can both receive events.
final controller = StreamController<List<Task>>.broadcast();
void emitDebounced(List<Task> tasks) {
debounceTimer?.cancel();
debounceTimer = Timer(const Duration(milliseconds: 150), () {
if (!controller.isClosed) controller.add(tasks);
});
}
ref.onDispose(() {
debounceTimer?.cancel();
controller.close();
});
// ── Immediate REST seed ───────────────────────────────────────────────────
// Fire a one-shot HTTP fetch right now so the UI can render before the
// WebSocket realtime channel is fully established. Eliminates loading delay
// on web and initial flash on mobile. Hash check prevents a duplicate
// rebuild if both the seed and the realtime stream arrive with the same data.
unawaited(
Future(() async {
try {
final data = await client.from('tasks').select();
final raw = data
.cast<Map<String, dynamic>>()
.map(Task.fromMap)
.toList();
final payload = _buildTaskPayload(
tasks: raw,
isGlobal: isGlobal,
allowedTicketIds: allowedTicketIds,
allowedOfficeIds: allowedOfficeIds,
query: query,
);
final processed = await compute(_processTasksInIsolate, payload);
final tasks = (processed as List<dynamic>)
.cast<Map<String, dynamic>>()
.map(Task.fromMap)
.toList();
final hash = tasks.fold('', (h, t) => '$h${t.id}');
if (!controller.isClosed && hash != lastResultHash) {
lastResultHash = hash;
controller.add(tasks); // emit immediately no debounce
}
} catch (e) {
debugPrint('[tasksProvider] initial seed error: $e');
}
}),
);
// ── Realtime stream ───────────────────────────────────────────────────────
// Processes every realtime event through the same isolate. Debounced so
// rapid consecutive events (e.g. bulk inserts) don't cause repeated renders.
final wrapperSub = wrapper.stream
.asyncMap((result) async {
final payload = _buildTaskPayload(
tasks: result.data,
isGlobal: isGlobal,
allowedTicketIds: allowedTicketIds,
allowedOfficeIds: allowedOfficeIds,
query: query,
);
final processed = await compute(_processTasksInIsolate, payload);
return (processed as List<dynamic>)
.cast<Map<String, dynamic>>()
.map(Task.fromMap)
.toList();
})
.listen(
(tasks) {
final hash = tasks.fold('', (h, t) => '$h${t.id}');
if (hash != lastResultHash) {
lastResultHash = hash;
emitDebounced(tasks);
}
},
onError: (Object e) {
debugPrint('[tasksProvider] stream error: $e');
// Don't forward errors — the wrapper handles recovery internally.
},
);
ref.onDispose(wrapperSub.cancel);
return controller.stream;
});
// 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);
final wrapper = StreamRecoveryWrapper<TaskAssignment>(
stream: client
.from('task_assignments')
.stream(primaryKey: ['task_id', 'user_id']),
onPollData: () async {
final data = await client.from('task_assignments').select();
return data.map(TaskAssignment.fromMap).toList();
},
fromMap: TaskAssignment.fromMap,
channelName: 'task_assignments',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
});
/// Stream of activity logs for a single task.
final taskActivityLogsProvider =
StreamProvider.family<List<TaskActivityLog>, String>((ref, taskId) {
final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<TaskActivityLog>(
stream: client
.from('task_activity_logs')
.stream(primaryKey: ['id'])
.eq('task_id', taskId)
.order('created_at', ascending: false),
onPollData: () async {
final data = await client
.from('task_activity_logs')
.select()
.eq('task_id', taskId)
.order('created_at', ascending: false);
return data.map((r) => TaskActivityLog.fromMap(r)).toList();
},
fromMap: TaskActivityLog.fromMap,
channelName: 'task_activity_logs:$taskId',
onStatusChanged: ref
.read(realtimeControllerProvider)
.handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
});
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> uploadTaskAttachment({
required String taskId,
required String fileName,
required Uint8List bytes,
}) async {
final path = '$taskId/$fileName';
try {
debugPrint('uploadTaskAttachment uploading to path: $path');
final dynamic res;
if (kIsWeb) {
// on web, upload binary data
res = await _client.storage
.from('task_attachments')
.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}_$fileName',
);
try {
await localFile.create();
await localFile.writeAsBytes(bytes);
} catch (e) {
debugPrint('uploadTaskAttachment failed writing temp file: $e');
rethrow;
}
res = await _client.storage
.from('task_attachments')
.upload(path, localFile);
try {
await localFile.delete();
} catch (_) {}
}
debugPrint(
'uploadTaskAttachment response type=${res.runtimeType} value=$res',
);
// Check for errors
if (res is String) {
// treat as success
} else if (res is Map && res['error'] != null) {
debugPrint('uploadTaskAttachment upload error: ${res['error']}');
throw Exception('Upload error: ${res['error']}');
} else if (res != null && res.error != null) {
debugPrint('uploadTaskAttachment upload error: ${res.error}');
throw Exception('Upload error: ${res.error}');
}
} catch (e) {
debugPrint('uploadTaskAttachment failed: $e');
rethrow;
}
}
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);
}
}