import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:flutter/material.dart'; import '../models/it_service_request.dart'; import '../models/it_service_request_assignment.dart'; import '../models/it_service_request_activity_log.dart'; import '../models/it_service_request_action.dart'; import 'profile_provider.dart'; import 'supabase_provider.dart'; import 'user_offices_provider.dart'; import 'stream_recovery.dart'; import 'realtime_controller.dart'; // --------------------------------------------------------------------------- // Query parameters // --------------------------------------------------------------------------- class ItServiceRequestQuery { const ItServiceRequestQuery({ this.offset = 0, this.limit = 50, this.searchQuery = '', this.officeId, this.status, this.dateRange, }); final int offset; final int limit; final String searchQuery; final String? officeId; final String? status; final DateTimeRange? dateRange; ItServiceRequestQuery copyWith({ int? offset, int? limit, String? searchQuery, String? officeId, String? status, DateTimeRange? dateRange, }) { return ItServiceRequestQuery( offset: offset ?? this.offset, limit: limit ?? this.limit, searchQuery: searchQuery ?? this.searchQuery, officeId: officeId ?? this.officeId, status: status ?? this.status, dateRange: dateRange ?? this.dateRange, ); } } final itServiceRequestQueryProvider = StateProvider( (ref) => const ItServiceRequestQuery(), ); // --------------------------------------------------------------------------- // Stream providers // --------------------------------------------------------------------------- final itServiceRequestsProvider = StreamProvider>((ref) { final userId = ref.watch(currentUserIdProvider); if (userId == null) return const Stream.empty(); final client = ref.watch(supabaseClientProvider); final profile = ref.watch(currentProfileProvider).valueOrNull; final userOfficesAsync = ref.watch(userOfficesProvider); final wrapper = StreamRecoveryWrapper( stream: client .from('it_service_requests') .stream(primaryKey: ['id']) .order('created_at', ascending: false), onPollData: () async { final data = await client .from('it_service_requests') .select() .order('created_at', ascending: false) .range(0, 199); return data.map(ItServiceRequest.fromMap).toList(); }, fromMap: ItServiceRequest.fromMap, channelName: 'it_service_requests', onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, ); ref.onDispose(wrapper.dispose); return wrapper.stream.map((result) { var items = result.data; // Standard users see only requests from their offices or created by them if (profile != null && profile.role == 'standard') { final officeIds = (userOfficesAsync.valueOrNull ?? []) .where((a) => a.userId == userId) .map((a) => a.officeId) .toSet(); items = items .where( (r) => r.creatorId == userId || (r.officeId != null && officeIds.contains(r.officeId)), ) .toList(); } return items; }); }); final itServiceRequestByIdProvider = Provider.family( (ref, id) { final requests = ref.watch(itServiceRequestsProvider).valueOrNull; if (requests == null) return null; try { return requests.firstWhere((r) => r.id == id); } catch (_) { return null; } }, ); final itServiceRequestAssignmentsProvider = StreamProvider>((ref) { final userId = ref.watch(currentUserIdProvider); if (userId == null) return const Stream.empty(); final client = ref.watch(supabaseClientProvider); final wrapper = StreamRecoveryWrapper( stream: client .from('it_service_request_assignments') .stream(primaryKey: ['id']) .order('created_at', ascending: false), onPollData: () async { final data = await client .from('it_service_request_assignments') .select() .order('created_at', ascending: false); return data.map(ItServiceRequestAssignment.fromMap).toList(); }, fromMap: ItServiceRequestAssignment.fromMap, channelName: 'it_service_request_assignments', onStatusChanged: ref .read(realtimeControllerProvider) .handleChannelStatus, ); ref.onDispose(wrapper.dispose); return wrapper.stream.map((result) => result.data); }); final itServiceRequestActivityLogsProvider = StreamProvider.family, String>(( ref, requestId, ) { final client = ref.watch(supabaseClientProvider); final wrapper = StreamRecoveryWrapper( stream: client .from('it_service_request_activity_logs') .stream(primaryKey: ['id']) .eq('request_id', requestId) .order('created_at', ascending: false), onPollData: () async { final data = await client .from('it_service_request_activity_logs') .select() .eq('request_id', requestId) .order('created_at', ascending: false); return data.map(ItServiceRequestActivityLog.fromMap).toList(); }, fromMap: ItServiceRequestActivityLog.fromMap, channelName: 'isr_activity_logs_$requestId', onStatusChanged: ref .read(realtimeControllerProvider) .handleChannelStatus, ); ref.onDispose(wrapper.dispose); return wrapper.stream.map((result) => result.data); }); final itServiceRequestActionsProvider = StreamProvider.family, String>(( ref, requestId, ) { final client = ref.watch(supabaseClientProvider); final wrapper = StreamRecoveryWrapper( stream: client .from('it_service_request_actions') .stream(primaryKey: ['id']) .eq('request_id', requestId) .order('created_at', ascending: false), onPollData: () async { final data = await client .from('it_service_request_actions') .select() .eq('request_id', requestId) .order('created_at', ascending: false); return data.map(ItServiceRequestAction.fromMap).toList(); }, fromMap: ItServiceRequestAction.fromMap, channelName: 'isr_actions_$requestId', onStatusChanged: ref .read(realtimeControllerProvider) .handleChannelStatus, ); ref.onDispose(wrapper.dispose); return wrapper.stream.map((result) => result.data); }); // --------------------------------------------------------------------------- // Controller // --------------------------------------------------------------------------- final itServiceRequestControllerProvider = Provider( (ref) { final client = ref.watch(supabaseClientProvider); return ItServiceRequestController(client); }, ); class ItServiceRequestController { ItServiceRequestController(this._client); final SupabaseClient _client; /// Creates a new IT Service Request with auto-generated number. Future> createRequest({ required String eventName, required List services, String? servicesOther, String? officeId, String? requestedBy, String? requestedByUserId, String status = 'draft', }) async { final userId = _client.auth.currentUser?.id; if (userId == null) throw Exception('Not authenticated'); try { final result = await _client.rpc( 'insert_it_service_request_with_number', params: { 'p_event_name': eventName, 'p_services': services, 'p_creator_id': userId, 'p_office_id': officeId, 'p_requested_by': requestedBy, 'p_requested_by_user_id': requestedByUserId, 'p_status': status, }, ); final row = result is List ? (result.first as Map) : result; final requestId = row['id'] as String; if (servicesOther != null && servicesOther.isNotEmpty) { await _client .from('it_service_requests') .update({'services_other': servicesOther}) .eq('id', requestId); } // Activity log await _client.from('it_service_request_activity_logs').insert({ 'request_id': requestId, 'actor_id': userId, 'action_type': 'created', }); return {'id': requestId, 'request_number': row['request_number']}; } catch (e) { debugPrint('createRequest error: $e'); rethrow; } } /// Updates IT Service Request fields. Future updateRequest({ required String requestId, String? eventName, List? services, String? servicesOther, String? eventDetails, DateTime? eventDate, DateTime? eventEndDate, DateTime? dryRunDate, DateTime? dryRunEndDate, String? contactPerson, String? contactNumber, String? remarks, String? officeId, String? requestedBy, String? requestedByUserId, String? approvedBy, String? approvedByUserId, bool? outsidePremiseAllowed, DateTime? dateTimeReceived, DateTime? dateTimeChecked, }) async { final updates = {}; if (eventName != null) updates['event_name'] = eventName; if (services != null) updates['services'] = services; if (servicesOther != null) updates['services_other'] = servicesOther; if (eventDetails != null) updates['event_details'] = eventDetails; if (eventDate != null) updates['event_date'] = eventDate.toIso8601String(); if (eventEndDate != null) { updates['event_end_date'] = eventEndDate.toIso8601String(); } if (dryRunDate != null) { updates['dry_run_date'] = dryRunDate.toIso8601String(); } if (dryRunEndDate != null) { updates['dry_run_end_date'] = dryRunEndDate.toIso8601String(); } if (contactPerson != null) updates['contact_person'] = contactPerson; if (contactNumber != null) updates['contact_number'] = contactNumber; if (remarks != null) updates['remarks'] = remarks; if (officeId != null) updates['office_id'] = officeId; if (requestedBy != null) updates['requested_by'] = requestedBy; if (requestedByUserId != null) { updates['requested_by_user_id'] = requestedByUserId; } if (approvedBy != null) updates['approved_by'] = approvedBy; if (approvedByUserId != null) { updates['approved_by_user_id'] = approvedByUserId; } if (outsidePremiseAllowed != null) { updates['outside_premise_allowed'] = outsidePremiseAllowed; } if (dateTimeReceived != null) { updates['date_time_received'] = dateTimeReceived.toIso8601String(); } if (dateTimeChecked != null) { updates['date_time_checked'] = dateTimeChecked.toIso8601String(); } if (updates.isEmpty) return; await _client .from('it_service_requests') .update(updates) .eq('id', requestId); // Log updated fields final userId = _client.auth.currentUser?.id; await _client.from('it_service_request_activity_logs').insert({ 'request_id': requestId, 'actor_id': userId, 'action_type': 'updated', 'meta': {'fields': updates.keys.toList()}, }); } /// Update only the status of an IT Service Request. Future updateStatus({ required String requestId, required String status, String? cancellationReason, }) async { final userId = _client.auth.currentUser?.id; final updates = {'status': status}; if (status == 'scheduled') { updates['approved_at'] = DateTime.now().toUtc().toIso8601String(); updates['approved_by_user_id'] = userId; } if (status == 'completed') { updates['completed_at'] = DateTime.now().toUtc().toIso8601String(); } if (status == 'cancelled') { updates['cancelled_at'] = DateTime.now().toUtc().toIso8601String(); if (cancellationReason != null) { updates['cancellation_reason'] = cancellationReason; } } await _client .from('it_service_requests') .update(updates) .eq('id', requestId); await _client.from('it_service_request_activity_logs').insert({ 'request_id': requestId, 'actor_id': userId, 'action_type': 'status_changed', 'meta': {'status': status}, }); } /// Approve a request (admin only). Sets status to 'scheduled'. Future approveRequest({ required String requestId, required String approverName, }) async { final userId = _client.auth.currentUser?.id; await _client .from('it_service_requests') .update({ 'status': 'scheduled', 'approved_by': approverName, 'approved_by_user_id': userId, 'approved_at': DateTime.now().toUtc().toIso8601String(), }) .eq('id', requestId); await _client.from('it_service_request_activity_logs').insert({ 'request_id': requestId, 'actor_id': userId, 'action_type': 'approved', }); } // ----------------------------------------------------------------------- // Assignment management // ----------------------------------------------------------------------- Future assignStaff({ required String requestId, required List userIds, }) async { final actorId = _client.auth.currentUser?.id; final rows = userIds .map((uid) => {'request_id': requestId, 'user_id': uid}) .toList(); await _client .from('it_service_request_assignments') .upsert(rows, onConflict: 'request_id,user_id'); await _client.from('it_service_request_activity_logs').insert({ 'request_id': requestId, 'actor_id': actorId, 'action_type': 'assigned', 'meta': {'user_ids': userIds}, }); } Future unassignStaff({ required String requestId, required String userId, }) async { final actorId = _client.auth.currentUser?.id; await _client .from('it_service_request_assignments') .delete() .eq('request_id', requestId) .eq('user_id', userId); await _client.from('it_service_request_activity_logs').insert({ 'request_id': requestId, 'actor_id': actorId, 'action_type': 'unassigned', 'meta': {'user_id': userId}, }); } // ----------------------------------------------------------------------- // Action Taken // ----------------------------------------------------------------------- Future createOrUpdateAction({ required String requestId, required String actionTaken, }) async { final userId = _client.auth.currentUser?.id; if (userId == null) throw Exception('Not authenticated'); // Check if action already exists for this user final existing = await _client .from('it_service_request_actions') .select('id') .eq('request_id', requestId) .eq('user_id', userId) .maybeSingle(); if (existing != null) { await _client .from('it_service_request_actions') .update({'action_taken': actionTaken}) .eq('id', existing['id']); return existing['id'] as String; } else { final result = await _client .from('it_service_request_actions') .insert({ 'request_id': requestId, 'user_id': userId, 'action_taken': actionTaken, }) .select('id') .single(); return result['id'] as String; } } // ----------------------------------------------------------------------- // Evidence Attachments // ----------------------------------------------------------------------- Future uploadEvidence({ required String requestId, required String actionId, required String fileName, required Uint8List bytes, DateTime? takenAt, }) async { final userId = _client.auth.currentUser?.id; if (userId == null) throw Exception('Not authenticated'); final path = '$requestId/evidence/$fileName'; if (kIsWeb) { await _client.storage .from('it_service_attachments') .uploadBinary( path, bytes, fileOptions: const FileOptions(upsert: true), ); } else { final tmpDir = Directory.systemTemp; final tmpFile = File('${tmpDir.path}/$fileName'); await tmpFile.writeAsBytes(bytes); await _client.storage .from('it_service_attachments') .upload(path, tmpFile, fileOptions: const FileOptions(upsert: true)); try { await tmpFile.delete(); } catch (_) {} } await _client.from('it_service_request_evidence').insert({ 'request_id': requestId, 'action_id': actionId, 'user_id': userId, 'file_path': path, 'file_name': fileName, 'taken_at': takenAt?.toIso8601String(), }); return _client.storage.from('it_service_attachments').getPublicUrl(path); } Future deleteEvidence({required String evidenceId}) async { // Get path first final row = await _client .from('it_service_request_evidence') .select('file_path') .eq('id', evidenceId) .single(); final path = row['file_path'] as String; await _client.storage.from('it_service_attachments').remove([path]); await _client .from('it_service_request_evidence') .delete() .eq('id', evidenceId); } // ----------------------------------------------------------------------- // File Attachments (event files, max 25MB) // ----------------------------------------------------------------------- Future uploadAttachment({ required String requestId, required String fileName, required Uint8List bytes, }) async { if (bytes.length > 25 * 1024 * 1024) { throw Exception('File size exceeds 25MB limit'); } final path = '$requestId/attachments/$fileName'; if (kIsWeb) { await _client.storage .from('it_service_attachments') .uploadBinary( path, bytes, fileOptions: const FileOptions(upsert: true), ); } else { final tmpDir = Directory.systemTemp; final tmpFile = File('${tmpDir.path}/$fileName'); await tmpFile.writeAsBytes(bytes); await _client.storage .from('it_service_attachments') .upload(path, tmpFile, fileOptions: const FileOptions(upsert: true)); try { await tmpFile.delete(); } catch (_) {} } return _client.storage.from('it_service_attachments').getPublicUrl(path); } Future>> listAttachments(String requestId) async { try { final files = await _client.storage .from('it_service_attachments') .list(path: '$requestId/attachments'); return files .map( (f) => { 'name': f.name, 'url': _client.storage .from('it_service_attachments') .getPublicUrl('$requestId/attachments/${f.name}'), }, ) .toList(); } catch (_) { return []; } } Future deleteAttachment({ required String requestId, required String fileName, }) async { final path = '$requestId/attachments/$fileName'; await _client.storage.from('it_service_attachments').remove([path]); } /// List evidence attachments for a request from the database. Future>> listEvidence(String requestId) async { final rows = await _client .from('it_service_request_evidence') .select() .eq('request_id', requestId) .order('created_at', ascending: false); return rows .map( (r) => { 'id': r['id'], 'file_name': r['file_name'], 'file_path': r['file_path'], 'taken_at': r['taken_at'], 'url': _client.storage .from('it_service_attachments') .getPublicUrl(r['file_path'] as String), }, ) .toList(); } }