import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../models/task.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'; /// 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>((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 []); } 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 earlyAllowedTicketIds = ticketsAsync.valueOrNull?.map((ticket) => ticket.id).toList() ?? []; List earlyOfficeIds = assignmentsAsync.valueOrNull ?.where((assignment) => assignment.userId == profile.id) .map((assignment) => assignment.officeId) .toSet() .toList() ?? []; if (!isGlobal && earlyAllowedTicketIds.isEmpty && earlyOfficeIds.isEmpty) { return Stream.value(const []); } // 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() ?? []; final officeIds = assignmentsAsync.valueOrNull ?.where((assignment) => assignment.userId == profile.id) .map((assignment) => assignment.officeId) .toSet() .toList() ?? []; if (allowedTicketIds.isEmpty && officeIds.isEmpty) return []; 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 []; return list.sublist(start, end); }); }); /// Provider for task query parameters. final tasksQueryProvider = StateProvider((ref) => const TaskQuery()); final taskAssignmentsProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); return client .from('task_assignments') .stream(primaryKey: ['task_id', 'user_id']) .map((rows) => rows.map(TaskAssignment.fromMap).toList()); }); final taskAssignmentsControllerProvider = Provider(( ref, ) { final client = ref.watch(supabaseClientProvider); return TaskAssignmentsController(client); }); final tasksControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); return TasksController(client); }); class TasksController { TasksController(this._client); final SupabaseClient _client; Future updateTaskStatus({ required String taskId, required String status, }) async { await _client.from('tasks').update({'status': status}).eq('id', taskId); } Future createTask({ required String title, required String description, required String officeId, }) async { final actorId = _client.auth.currentUser?.id; final data = await _client .from('tasks') .insert({ 'title': title, 'description': description, 'office_id': officeId, }) .select('id') .single(); final taskId = data['id'] as String?; if (taskId == null) return; unawaited(_notifyCreated(taskId: taskId, actorId: actorId)); } Future _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> _fetchRoleUserIds({ required List roles, required String? excludeUserId, }) async { try { final data = await _client .from('profiles') .select('id, role') .inFilter('role', roles); final rows = data as List; final ids = rows .map((row) => row['id'] as String?) .whereType() .where((id) => id.isNotEmpty && id != excludeUserId) .toList(); return ids; } catch (_) { return []; } } } class TaskAssignmentsController { TaskAssignmentsController(this._client); final SupabaseClient _client; Future replaceAssignments({ required String taskId, required String? ticketId, required List newUserIds, required List 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); 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); } } Future _notifyAssigned({ required String taskId, required String? ticketId, required List 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 removeAssignment({ required String taskId, required String userId, }) async { await _client .from('task_assignments') .delete() .eq('task_id', taskId) .eq('user_id', userId); } }