From 6d70afc779256499335f01de36c3e76dd67caa46 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 11 Sep 2025 15:45:32 +0530 Subject: [PATCH] feat: Add document verification and rejection functionality with remote logging support --- lib/controller/auth/login_controller.dart | 3 + .../document/document_details_controller.dart | 20 +++ lib/helpers/services/api_endpoints.dart | 7 +- lib/helpers/services/api_service.dart | 83 ++++++++++++ lib/helpers/services/app_logger.dart | 128 ++++++++++++------ lib/main.dart | 8 +- lib/view/document/document_details_page.dart | 75 ++++++++++ 7 files changed, 279 insertions(+), 45 deletions(-) diff --git a/lib/controller/auth/login_controller.dart b/lib/controller/auth/login_controller.dart index 30418bc..0ae276f 100644 --- a/lib/controller/auth/login_controller.dart +++ b/lib/controller/auth/login_controller.dart @@ -75,6 +75,9 @@ class LoginController extends MyController { basicValidator.clearErrors(); } else { await _handleRememberMe(); + // ✅ Enable remote logging after successful login + enableRemoteLogging(); + logSafe("✅ Remote logging enabled after login."); // ✅ Commented out FCM token registration after login /* diff --git a/lib/controller/document/document_details_controller.dart b/lib/controller/document/document_details_controller.dart index 6abd95f..83f0564 100644 --- a/lib/controller/document/document_details_controller.dart +++ b/lib/controller/document/document_details_controller.dart @@ -45,6 +45,26 @@ class DocumentDetailsController extends GetxController { } } + /// Verify document + Future verifyDocument(String documentId) async { + final result = + await ApiService.verifyDocumentApi(id: documentId, isVerify: true); + if (result) { + await fetchDocumentDetails(documentId); // refresh details + } + return result; + } + + /// Reject document + Future rejectDocument(String documentId) async { + final result = + await ApiService.verifyDocumentApi(id: documentId, isVerify: false); + if (result) { + await fetchDocumentDetails(documentId); // refresh details + } + return result; + } + /// Fetch Pre-Signed URL for a given version Future fetchPresignedUrl(String versionId) async { return await ApiService.getPresignedUrlApi(versionId); diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 2f2a32f..eee0216 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -1,7 +1,8 @@ class ApiEndpoints { static const String baseUrl = "https://stageapi.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api"; -// + // static const String baseUrl = "https://devapi.marcoaiot.com/api"; + // Dashboard Module API Endpoints static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview"; @@ -85,4 +86,8 @@ class ApiEndpoints { static const String getDocumentVersion = "/document/get/version"; static const String getDocumentVersions = "/document/list/versions"; static const String editDocument = "/document/edit"; + static const String verifyDocument = "/document/verify"; + + /// Logs Module API Endpoints + static const String uploadLogs = "/log"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 5d1a1e8..4b97336 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -247,6 +247,89 @@ class ApiService { } } + static Future postLogsApi(List> logs) async { + const endpoint = "${ApiEndpoints.uploadLogs}"; + logSafe("Posting logs... count=${logs.length}"); + + try { + final response = + await _postRequest(endpoint, logs, customTimeout: extendedTimeout); + + if (response == null) { + logSafe("Post logs failed: null response", level: LogLevel.error); + return false; + } + + logSafe("Post logs response status: ${response.statusCode}"); + logSafe("Post logs response body: ${response.body}"); + + if (response.statusCode == 200 && response.body.isNotEmpty) { + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Logs posted successfully."); + return true; + } + } + + logSafe("Failed to post logs: ${response.body}", level: LogLevel.warning); + } catch (e, stack) { + logSafe("Exception during postLogsApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + + /// Verify Document API + static Future verifyDocumentApi({ + required String id, + bool isVerify = true, + }) async { + final endpoint = "${ApiEndpoints.verifyDocument}/$id"; + final queryParams = {"isVerify": isVerify.toString()}; + logSafe("Verifying document with id: $id | isVerify: $isVerify"); + + try { + final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint") + .replace(queryParameters: queryParams); + + String? token = await _getToken(); + if (token == null) return false; + + final headers = _headers(token); + logSafe("POST (verify) $uri\nHeaders: $headers"); + + final response = + await http.post(uri, headers: headers).timeout(extendedTimeout); + + if (response.statusCode == 401) { + logSafe("Unauthorized VERIFY. Attempting token refresh..."); + if (await AuthService.refreshToken()) { + return await verifyDocumentApi(id: id, isVerify: isVerify); + } + } + + logSafe("Verify document response status: ${response.statusCode}"); + logSafe("Verify document response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Document verify success: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to verify document: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + } + } catch (e, stack) { + logSafe("Exception during verifyDocumentApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + /// Get Pre-Signed URL for Old Version static Future getPresignedUrlApi(String versionId) async { final endpoint = "${ApiEndpoints.getDocumentVersion}/$versionId"; diff --git a/lib/helpers/services/app_logger.dart b/lib/helpers/services/app_logger.dart index 9047066..9e855e9 100644 --- a/lib/helpers/services/app_logger.dart +++ b/lib/helpers/services/app_logger.dart @@ -2,16 +2,41 @@ import 'dart:io'; import 'package:logger/logger.dart'; import 'package:intl/intl.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:marco/helpers/services/api_service.dart'; /// Global logger instance -late final Logger appLogger; -late final FileLogOutput fileLogOutput; +Logger? _appLogger; +late final FileLogOutput _fileLogOutput; + +/// Store logs temporarily for API posting +final List> _logBuffer = []; + +/// Lock flag to prevent concurrent posting +bool _isPosting = false; + +/// Flag to allow API posting only after login +bool _canPostLogs = false; + +/// Maximum number of logs before triggering API post +const int _maxLogsBeforePost = 50; + +/// Maximum logs in memory buffer +const int _maxBufferSize = 50; + +/// Enum → logger level mapping +const _levelMap = { + LogLevel.debug: Level.debug, + LogLevel.info: Level.info, + LogLevel.warning: Level.warning, + LogLevel.error: Level.error, + LogLevel.verbose: Level.verbose, +}; /// Initialize logging Future initLogging() async { - fileLogOutput = FileLogOutput(); + _fileLogOutput = FileLogOutput(); - appLogger = Logger( + _appLogger = Logger( printer: PrettyPrinter( methodCount: 0, printTime: true, @@ -20,12 +45,18 @@ Future initLogging() async { ), output: MultiOutput([ ConsoleOutput(), - fileLogOutput, + _fileLogOutput, ]), level: Level.debug, ); } +/// Enable API posting after login +void enableRemoteLogging() { + _canPostLogs = true; + _postBufferedLogs(); // flush logs if any +} + /// Safe logger wrapper void logSafe( String message, { @@ -34,27 +65,60 @@ void logSafe( StackTrace? stackTrace, bool sensitive = false, }) { - if (sensitive) return; + if (sensitive || _appLogger == null) return; - switch (level) { - case LogLevel.debug: - appLogger.d(message, error: error, stackTrace: stackTrace); - break; - case LogLevel.warning: - appLogger.w(message, error: error, stackTrace: stackTrace); - break; - case LogLevel.error: - appLogger.e(message, error: error, stackTrace: stackTrace); - break; - case LogLevel.verbose: - appLogger.v(message, error: error, stackTrace: stackTrace); - break; - default: - appLogger.i(message, error: error, stackTrace: stackTrace); + final loggerLevel = _levelMap[level] ?? Level.info; + _appLogger!.log(loggerLevel, message, error: error, stackTrace: stackTrace); + + // Buffer logs for API posting + _logBuffer.add({ + "logLevel": level.name, + "message": message, + "timeStamp": DateTime.now().toUtc().toIso8601String(), + "ipAddress": "this is test IP", // TODO: real IP + "userAgent": "FlutterApp/1.0", // TODO: device_info_plus + "details": error?.toString() ?? stackTrace?.toString(), + }); + + if (_logBuffer.length >= _maxLogsBeforePost) { + _postBufferedLogs(); } } -/// Log output to file (safe path, no permission required) +/// Post buffered logs to API +Future _postBufferedLogs() async { + if (!_canPostLogs) return; // 🚫 skip if not logged in + if (_isPosting || _logBuffer.isEmpty) return; + + _isPosting = true; + final logsToSend = List>.from(_logBuffer); + _logBuffer.clear(); + + try { + final success = await ApiService.postLogsApi(logsToSend); + if (!success) { + _reinsertLogs(logsToSend, reason: "API call returned false"); + } + } catch (e) { + _reinsertLogs(logsToSend, reason: "API exception: $e"); + } finally { + _isPosting = false; + } +} + +/// Reinsert logs into buffer if posting fails +void _reinsertLogs(List> logs, {required String reason}) { + _appLogger?.w("Failed to post logs, re-queuing. Reason: $reason"); + + if (_logBuffer.length + logs.length > _maxBufferSize) { + _appLogger?.e("Buffer full. Dropping ${logs.length} logs to prevent crash."); + return; + } + + _logBuffer.insertAll(0, logs); +} + +/// File-based log output (safe storage) class FileLogOutput extends LogOutput { File? _logFile; @@ -81,7 +145,6 @@ class FileLogOutput extends LogOutput { @override void output(OutputEvent event) async { await _init(); - if (event.lines.isEmpty) return; final logMessage = event.lines.join('\n') + '\n'; @@ -122,22 +185,5 @@ class FileLogOutput extends LogOutput { } } -/// Simple log printer for file output -class SimpleFileLogPrinter extends LogPrinter { - @override - List log(LogEvent event) { - final message = event.message.toString(); - - if (message.contains('[SENSITIVE]')) return []; - - final timestamp = DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()); - final level = event.level.name.toUpperCase(); - final error = event.error != null ? ' | ERROR: ${event.error}' : ''; - final stack = - event.stackTrace != null ? '\nSTACKTRACE:\n${event.stackTrace}' : ''; - return ['[$timestamp] [$level] $message$error$stack']; - } -} - -/// Optional enum for log levels +/// Custom log levels enum LogLevel { debug, info, warning, error, verbose } diff --git a/lib/main.dart b/lib/main.dart index 8b8bb2e..fb56445 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,7 +13,7 @@ Future main() async { await initLogging(); logSafe("App starting..."); - + enableRemoteLogging(); try { await initializeApp(); logSafe("App initialized successfully."); @@ -73,9 +73,11 @@ class _MainWrapperState extends State { @override Widget build(BuildContext context) { - final bool isOffline = _connectivityStatus.contains(ConnectivityResult.none); + final bool isOffline = + _connectivityStatus.contains(ConnectivityResult.none); return isOffline - ? const MaterialApp(debugShowCheckedModeBanner: false, home: OfflineScreen()) + ? const MaterialApp( + debugShowCheckedModeBanner: false, home: OfflineScreen()) : const MyApp(); } } diff --git a/lib/view/document/document_details_page.dart b/lib/view/document/document_details_page.dart index d7cea7b..695f590 100644 --- a/lib/view/document/document_details_page.dart +++ b/lib/view/document/document_details_page.dart @@ -229,6 +229,81 @@ class _DocumentDetailsPageState extends State { _buildDetailRow("Uploaded On", uploadDate), if (doc.updatedAt != null) _buildDetailRow("Last Updated On", updateDate), + MySpacing.height(12), + if (permissionController + .hasPermission(Permissions.verifyDocument)) ...[ + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.check, color: Colors.white), + label: MyText.bodyMedium( + "Verify", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 8), + ), + onPressed: () async { + final success = await controller.verifyDocument(doc.id); + if (success) { + showAppSnackbar( + title: "Success", + message: "Document verified successfully", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to verify document", + type: SnackbarType.error, + ); + } + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.close, color: Colors.white), + label: MyText.bodyMedium( + "Reject", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 8), + ), + onPressed: () async { + final success = await controller.rejectDocument(doc.id); + if (success) { + showAppSnackbar( + title: "Rejected", + message: "Document rejected successfully", + type: SnackbarType.warning, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to reject document", + type: SnackbarType.error, + ); + } + }, + ), + ), + ], + ), + ], ], ), );