MotorSignal-App/lib/services/cloud/account_cloud_cleanup_servi...

121 lines
4.6 KiB
Dart

import 'dart:convert';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../auth/auth_service.dart';
import '../sync/r2_upload_service.dart';
abstract class CloudAccountCleanup {
Future<void> deleteCloudData();
}
class AccountCloudCleanupService implements CloudAccountCleanup {
final AuthService _auth;
final FirebaseFirestore? _firestore;
final http.Client _client;
final String _workerUrl;
AccountCloudCleanupService({
required this._auth,
FirebaseFirestore? firestore,
http.Client? client,
String? workerUrl,
}) : _firestore = firestore ?? _resolveFirestore(),
_client = client ?? http.Client(),
_workerUrl = (workerUrl ?? R2UploadService.defaultWorkerUrl).replaceAll(RegExp(r'/+$'), '');
static FirebaseFirestore? _resolveFirestore() {
try {
if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance;
} catch (_) {}
return null;
}
@override
Future<void> deleteCloudData() async {
final user = _auth.currentUser;
if (user == null || user.isAnonymous) throw StateError('A permanent account is required.');
final token = await _auth.getIdToken(true);
if (token == null || token.isEmpty) throw StateError('A valid account session is required.');
final endpoint = Uri.tryParse('$_workerUrl/account/cloud-data');
if (endpoint == null || !endpoint.hasScheme ||
(endpoint.scheme != 'http' && endpoint.scheme != 'https')) {
throw StateError('MotorCloud cleanup endpoint is unavailable.');
}
final response = await _client.delete(
endpoint,
headers: {'Authorization': 'Bearer ${token.trim()}'},
).timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
final detail = _safeResponseDetail(response.body);
debugPrint(
'[DELETE_FLOW] cloud cleanup response status=${response.statusCode} '
'body=$detail',
);
throw StateError(
'MotorCloud cleanup was not completed (${response.statusCode}).',
);
}
if (response.statusCode != 204 && !_isSuccessfulWorkerResponse(response.body)) {
final detail = _safeResponseDetail(response.body);
debugPrint('[DELETE_FLOW] cloud cleanup response malformed body=$detail');
throw StateError('MotorCloud cleanup returned an invalid response.');
}
final firestore = _firestore;
if (firestore == null) throw StateError('MotorCloud database is unavailable.');
final userRef = firestore.collection('users').doc(user.uid);
final rides = await userRef.collection('rides').get();
for (final doc in rides.docs) {
await _deleteCollection(doc.reference.collection('events'));
await _deleteCollection(doc.reference.collection('performance'));
}
await _deleteCollection(userRef.collection('rides'));
await _deleteCollection(userRef.collection('motorcycles'));
await _deleteCollection(userRef.collection('trips'));
await _deleteCollection(userRef.collection('events'));
await _deleteCollection(userRef.collection('friends'));
// Delete the profile last so retries can still discover the account scope.
final profileBatch = firestore.batch()..delete(userRef);
await profileBatch.commit();
}
Future<void> _deleteCollection(CollectionReference<Map<String, dynamic>> collection) async {
final snapshot = await collection.get();
for (var offset = 0; offset < snapshot.docs.length; offset += 450) {
final batch = _firestore!.batch();
final end = (offset + 450).clamp(0, snapshot.docs.length);
for (final doc in snapshot.docs.sublist(offset, end)) {
batch.delete(doc.reference);
}
await batch.commit();
}
}
String _safeResponseDetail(String body) {
final sanitized = body
.replaceAll(RegExp(r'Bearer\s+\S+', caseSensitive: false), 'Bearer [REDACTED]')
.replaceAll(RegExp(r'"(?:token|password|secret|pat)"\s*:\s*"[^"]*"', caseSensitive: false), '"credential":"[REDACTED]"');
final compact = sanitized.replaceAll(RegExp(r'\s+'), ' ').trim();
if (compact.isEmpty) return '[empty]';
return compact.length > 240 ? compact.substring(0, 240) : compact;
}
bool _isSuccessfulWorkerResponse(String body) {
try {
final decoded = jsonDecode(body);
return decoded is Map<String, dynamic> &&
decoded['success'] == true &&
decoded['deletedObjects'] is int &&
(decoded['deletedObjects'] as int) >= 0;
} on FormatException {
return false;
}
}
}