feat: Add document verification and rejection functionality with remote logging support

This commit is contained in:
Vaibhav Surve 2025-09-11 15:45:32 +05:30
parent a02887845b
commit 6d70afc779
7 changed files with 279 additions and 45 deletions

View File

@ -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
/*

View File

@ -45,6 +45,26 @@ class DocumentDetailsController extends GetxController {
}
}
/// Verify document
Future<bool> 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<bool> 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<String?> fetchPresignedUrl(String versionId) async {
return await ApiService.getPresignedUrlApi(versionId);

View File

@ -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";
}

View File

@ -247,6 +247,89 @@ class ApiService {
}
}
static Future<bool> postLogsApi(List<Map<String, dynamic>> 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<bool> 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<String?> getPresignedUrlApi(String versionId) async {
final endpoint = "${ApiEndpoints.getDocumentVersion}/$versionId";

View File

@ -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<Map<String, dynamic>> _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<void> initLogging() async {
fileLogOutput = FileLogOutput();
_fileLogOutput = FileLogOutput();
appLogger = Logger(
_appLogger = Logger(
printer: PrettyPrinter(
methodCount: 0,
printTime: true,
@ -20,12 +45,18 @@ Future<void> 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<void> _postBufferedLogs() async {
if (!_canPostLogs) return; // 🚫 skip if not logged in
if (_isPosting || _logBuffer.isEmpty) return;
_isPosting = true;
final logsToSend = List<Map<String, dynamic>>.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<Map<String, dynamic>> 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<String> 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 }

View File

@ -13,7 +13,7 @@ Future<void> main() async {
await initLogging();
logSafe("App starting...");
enableRemoteLogging();
try {
await initializeApp();
logSafe("App initialized successfully.");
@ -73,9 +73,11 @@ class _MainWrapperState extends State<MainWrapper> {
@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();
}
}

View File

@ -229,6 +229,81 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
_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,
);
}
},
),
),
],
),
],
],
),
);