From fcd39710792d85f8e2b04feec89ace551c000879 Mon Sep 17 00:00:00 2001 From: "torok.istvan" Date: Sun, 9 Aug 2026 23:18:06 +0200 Subject: [PATCH] =?UTF-8?q?Projektkezel=C3=A9ssel=20kapcsolatos=20hibajav?= =?UTF-8?q?=C3=ADt=C3=A1sok,=20projekt=20t=C3=B6rl=C3=A9se=20a=20szerveren?= =?UTF-8?q?,=20bejelentkez=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/services/app_database.dart | 92 +++++++- lib/services/project_service.dart | 232 ++++++++++++++++--- lib/services/ts_sync_service.dart | 17 +- lib/widgets/project/project_picker_view.dart | 13 +- 4 files changed, 317 insertions(+), 37 deletions(-) diff --git a/lib/services/app_database.dart b/lib/services/app_database.dart index 212806e..2c17728 100644 --- a/lib/services/app_database.dart +++ b/lib/services/app_database.dart @@ -15,6 +15,7 @@ import 'package:terepi_seged/models/source_point.dart'; import 'package:terepi_seged/models/stakeout_point.dart'; import 'package:terepi_seged/models/track.dart'; import 'package:terepi_seged/models/vechicle_position_log.dart'; +import 'package:terepi_seged/services/app_logger.dart'; import 'package:terepi_seged/services/device_identity_service.dart'; import 'package:uuid/uuid.dart'; import '../models/project.dart'; @@ -46,7 +47,7 @@ class AppDatabase { final path = p.join(dbDir.path, 'terepi_seged.db'); return openDatabase(path, - version: 7, + version: 8, onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: _onCreate, onUpgrade: _onUpgrade); @@ -239,7 +240,7 @@ class AppDatabase { vertical_error REAL, description TEXT, is_deleted INTEGER NOT NULL DEFAULT 0, - project_id INTEGER NOT NULL DEFAULT 2, + project_id INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, sync_status TEXT NOT NULL DEFAULT 'pending' ) @@ -274,6 +275,7 @@ class AppDatabase { await _addAppInstanceIdColumns(db); await _addContactLocationColumns(db); + await _addProjectMissingStreakColumn(db); await _createVibratorNavTables(db); @@ -315,6 +317,7 @@ class AppDatabase { await _addAppInstanceIdColumns(db); await _createVibratorNavTables(db); await _addContactLocationColumns(db); + await _addProjectMissingStreakColumn(db); } Future _migrateToV4(Database db) async { @@ -1406,6 +1409,11 @@ class AppDatabase { } } + Future _addProjectMissingStreakColumn(Database db) async { + await _tryExec(db, + 'ALTER TABLE projects ADD COLUMN missing_streak INTEGER NOT NULL DEFAULT 0'); + } + // Future testOnly() async { // final db = await database; // await db.execute( @@ -1493,4 +1501,84 @@ class AppDatabase { where: 'project_id = ?', whereArgs: [projectId]); return rows.map(VehiclePositionLog.fromMap).toList(); } + + /// Összeveti a szerver "aktív tagság" listáját a helyi, SZINKRONIZÁLT + /// (nem csak-lokális) projektekkel. Ami tartósan (több ciklusban) + /// hiányzik onnan, azt — a helyi gyerek-adatokkal EGYÜTT — törli. + /// Szándékosan NEM azonnal töröl egyetlen hiányzás után, hogy egy + /// átmeneti hálózati/RLS-hiba ne okozhasson véletlen adatvesztést. + Future reconcileMissingProjects(Set remoteUuids) async { + const missingThreshold = 3; // ennyi egymást követő ciklus után törlünk + + final db = await database; + final localSynced = await db.query('projects', + where: 'is_local_only = 0', + columns: ['id', 'uuid', 'name', 'missing_streak']); + + for (final row in localSynced) { + final localId = row['id'] as int; + final uuid = row['uuid'] as String; + final streak = (row['missing_streak'] as int?) ?? 0; + + if (remoteUuids.contains(uuid)) { + if (streak != 0) { + await db.update('projects', {'missing_streak': 0}, + where: 'id = ?', whereArgs: [localId]); + } + continue; + } + + final newStreak = streak + 1; + if (newStreak >= missingThreshold) { + await _cascadeDeleteLocalProject(localId, uuid, row['name'] as String?); + } else { + await db.update('projects', {'missing_streak': newStreak}, + where: 'id = ?', whereArgs: [localId]); + } + } + } + + /// A projekt ÉS minden helyi, hozzá kötött adat törlése — a szerveren + /// már nem létező (törölt vagy tagságból kikerült) projekt helyi + /// árváinak eltávolítása. + /// + /// A track_points (tracks-hoz) és a note_item_photos/note_item_audios + /// (note_items-hez) már ON DELETE CASCADE-del hivatkoznak a szülőre, és + /// a PRAGMA foreign_keys = ON aktív (onConfigure) — ezeket a SQLite + /// automatikusan törli, nem kell kézzel foglalkozni velük. + /// + /// A contacts_outbox és a pending_points külön figyelmet igényel: + /// a contacts_outbox a projekt UUID-jével (nem a helyi int id-vel) + /// van kulcsolva, a pending_points viszont a szokásos int id-vel. + Future _cascadeDeleteLocalProject( + int localProjectId, String projectUuid, String? name) async { + final db = await database; + await db.transaction((txn) async { + for (final table in [ + 'tracks', // → track_points automatikusan + 'measured_points', + 'note_items', // → note_item_photos/audios automatikusan + 'stakeout_points', + 'imported_layers', + 'source_points', + 'vehicle_position_logs', + 'pending_points', + ]) { + await txn.delete(table, + where: 'project_id = ?', whereArgs: [localProjectId]); + } + + // A contacts_outbox a projekt UUID-jét használja kulcsként. + await txn.delete('contacts_outbox', + where: 'project_id = ?', whereArgs: [projectUuid]); + + await txn + .delete('projects', where: 'id = ?', whereArgs: [localProjectId]); + }); + + AppLogger.e( + 'AppDatabase', + 'Projekt helyi törlése: "$name" (id=$localProjectId) — a szerveren ' + 'már nem szerepel a tagsági listában (törölve vagy kikerültünk).'); + } } diff --git a/lib/services/project_service.dart b/lib/services/project_service.dart index c09d15b..b79297c 100644 --- a/lib/services/project_service.dart +++ b/lib/services/project_service.dart @@ -1,6 +1,8 @@ import 'package:get/get.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:terepi_seged/services/app_logger.dart'; +import 'package:terepi_seged/services/device_identity_service.dart'; import 'package:uuid/uuid.dart'; import '../models/project.dart'; import 'app_database.dart'; @@ -15,6 +17,23 @@ class ProjectRequiresLoginException implements Exception { String toString() => message; } +class ProjectArchiveBlockedException implements Exception { + final String message; + ProjectArchiveBlockedException(this.message); + @override + String toString() => message; +} + +class ProjectNoLongerExistsException implements Exception { + final String message; + ProjectNoLongerExistsException([ + this.message = 'Ez a projekt már nem érhető el — törölték, vagy ' + 'kikerültél a tagságából.', + ]); + @override + String toString() => message; +} + class ProjectService extends GetxService { static ProjectService get to => Get.find(); @@ -60,24 +79,90 @@ class ProjectService extends GetxService { return; } } - // Fallback: az első aktív projekt - if (projects.isNotEmpty) { - await setActiveProject(projects.first); + // Fallback: elsőként lokális projektet próbálunk (mindig biztonságos), + // csak ha nincs, esünk vissza bármelyikre — és a hibát itt is elkapjuk, + // hogy egy bejelentkezés-igénylő projekt ne akassza meg az indulást. + final fallback = projects.firstWhereOrNull((p) => p.isLocalOnly) ?? + (projects.isNotEmpty ? projects.first : null); + if (fallback != null) { + try { + await setActiveProject(fallback); + } catch (_) { + // Nincs aktiválható projekt most (pl. csak felhős van, bejelentkezés + // nélkül) — activeProject marad null, a UI ezt már kezeli. + } } } Future setActiveProject(Project project) async { if (!project.isLocalOnly && Supabase.instance.client.auth.currentUser == null) { + AppLogger.event('project_activate_blocked_no_login', '', { + 'device_id': DeviceIdentityService.to.deviceId, + 'device_name': DeviceIdentityService.to.deviceLabel.value, + }); throw ProjectRequiresLoginException(); } + // Felhős projektnél: AZONNALI, élő ellenőrzés — nem várjuk meg a + // háttér-reconcile 3 ciklusos türelmi idejét. Az azért van, hogy egy + // ÁTMENETI hálózati/RLS-hiccup ne váltson ki téves törlést — itt + // viszont egyetlen, konkrét projektet kérdezünk le frissen, ahol + // nincs ilyen bizonytalanság: a deleted_at vagy ki van töltve, vagy + // nincs. Egy már bizonyítottan törölt projekthez való csatlakozás + // komoly félreértések forrása lenne (adatrögzítés egy halott + // projektbe), ezt nem érdemes kockáztatni pár perc türelmi idő + // kedvéért. + if (!project.isLocalOnly) { + try { + final row = await Supabase.instance.client + .from('terepi_seged_projects') + .select('deleted_at') + .eq('id', project.uuid) + .maybeSingle() + .timeout(const Duration(seconds: 6)); + + if (row == null || row['deleted_at'] != null) { + AppLogger.event('project_activate_blocked_deleted', '', { + 'project_id': project.id, + 'device_id': DeviceIdentityService.to.deviceId, + 'device_name': DeviceIdentityService.to.deviceLabel.value, + 'user_id': Supabase.instance.client.auth.currentUser?.id, + }); + throw ProjectNoLongerExistsException(); + } + } on ProjectNoLongerExistsException { + rethrow; + } catch (_) { + // Hálózati hiba/időtúllépés → NEM blokkolunk emiatt (fail-open, + // offline-first elv) — ha tényleg törölve van, a háttér-reconcile + // úgyis elkapja hamarosan. + } + } + + // Helyi (SQLite) meglét ellenőrzése — ne aktiváljunk egy már a + // háttér-reconcile által helyileg törölt projektet sem. + final stillExists = await AppDatabase.instance.getProject(project.id!); + if (stillExists == null) { + await _loadProjects(); // a lista is frissüljön, ne maradjon árva + AppLogger.event('project_activate_blocked_gone', '', { + 'project_id': project.id, + 'device_id': DeviceIdentityService.to.deviceId, + 'device_name': DeviceIdentityService.to.deviceLabel.value, + 'user_id': Supabase.instance.client.auth.currentUser?.id, + }); + throw ProjectNoLongerExistsException(); + } activeProject.value = project; final prefs = await SharedPreferences.getInstance(); await prefs.setInt('active_project_id', project.id!); - // Frissítjük az updated_at-et hogy a lista tetejére kerüljön - await AppDatabase.instance.updateProject(project.copyWith()); + // Csak lokális projektnél frissítjük az updated_at-et (lista-sorrendhez) + // — felhősnél ez feleslegesen szinkron-jelet váltana ki, ütközési + // kockázattal egy másik eszköz közbeni, valódi módosításával szemben. + if (project.isLocalOnly) { + await AppDatabase.instance.updateProject(project.copyWith()); + } await _loadProjects(); } @@ -132,11 +217,6 @@ class ProjectService extends GetxService { // Lokálisan mentjük final id = await AppDatabase.instance.insertProject(project); - // // Supabase-be is - // await Supabase.instance.client - // .from('TerepiSeged_Projects') - // .insert(project.toMap()); - await _loadProjects(); // Ha van net, azonnal fel is megy (és owner-tagság is létrejön). @@ -144,6 +224,8 @@ class ProjectService extends GetxService { TsSyncService.to.syncNow(); } + AppLogger.event('project_created_online', project.uuid, + {'name': name, 'client': client}); return await AppDatabase.instance.getProject(id) ?? project; } @@ -168,6 +250,9 @@ class ProjectService extends GetxService { // Csak lokálisan final id = await AppDatabase.instance.insertProject(project); await _loadProjects(); + AppLogger.event('project_created_local', project.uuid, + {'name': name, 'client': client}); + return await AppDatabase.instance.getProject(id) ?? project; } @@ -194,27 +279,34 @@ class ProjectService extends GetxService { final client = Supabase.instance.client; final user = client.auth.currentUser; if (user == null) { + AppLogger.event('project_join_blocked_no_login'); throw ProjectRequiresLoginException( 'Közös projekthez csatlakozáshoz be kell jelentkezni.'); } final projectUuid = sharedRow['id'] as String; - // 1. Tagság (idempotens: ha már tag, nem hiba). - await client.from('terepi_seged_project_members').upsert( - { - 'project_id': projectUuid, - 'user_id': user.id, - 'role': 'editor', - }, - ignoreDuplicates: true, - ); + try { + await client.from('terepi_seged_project_members').upsert( + { + 'project_id': projectUuid, + 'user_id': user.id, + 'role': 'editor', + }, + ignoreDuplicates: true, + ); + } catch (e) { + AppLogger.e('ProjectService.joinSharedProject', + 'Tagság-beszúrás hiba (project=$projectUuid): $e'); + rethrow; + } - // 2. Lokális projekt-sor a távoli uuid-dal. final localId = await AppDatabase.instance.upsertProjectFromRemote(sharedRow); await _loadProjects(); - // 3. Adatok letöltése háttérben. + AppLogger.event( + 'project_joined', projectUuid, {'user_id': user.id, 'role': 'editor'}); + if (Get.isRegistered()) { TsSyncService.to.syncNow(); } @@ -227,26 +319,104 @@ class ProjectService extends GetxService { Future leaveSharedProject(Project project) async { final client = Supabase.instance.client; final user = client.auth.currentUser; - if (user == null) return; // nincs bejelentkezve — nincs mit tenni - await client - .from('terepi_seged_project_members') - .delete() - .eq('project_id', project.uuid) - .eq('user_id', user.id); + if (user == null) return; + try { + await client + .from('terepi_seged_project_members') + .delete() + .eq('project_id', project.uuid) + .eq('user_id', user.id); + AppLogger.event( + 'shared_project_left', project.uuid, {'user_id': user.id}); + } catch (e) { + AppLogger.e('ProjectService.leaveSharedProject', + 'Kilépés hiba (project=${project.uuid}): $e'); + rethrow; + } } // ═════════════════════════════════════════════════════════════════ - Future reloadProjects() => _loadProjects(); + /// A projekt-lista frissítése — a háttér-szinkron hívja, miután a + /// reconcile-mechanizmus esetleg helyileg törölt egy, a szerveren már + /// nem létező projektet. Ha épp az AKTÍV projekt tűnt el, biztonságos + /// másikra váltunk, ne maradjon egy már nem létező projektre mutatva. + Future reloadProjects() async { + await _loadProjects(); + + final active = activeProject.value; + if (active == null) return; + + final stillThere = projects.firstWhereOrNull((p) => p.id == active.id); + if (stillThere == null) { + // A projekt eltűnt — biztonságos másikra váltunk. + final fallback = projects.firstWhereOrNull((p) => p.isLocalOnly) ?? + (projects.isNotEmpty ? projects.first : null); + if (fallback != null) { + try { + await setActiveProject(fallback); + } catch (_) { + activeProject.value = null; + } + } else { + activeProject.value = null; + } + } else { + // A projekt megvan — de az ADATAI (pl. a neve) változhattak a + // szerveren. Az activeProject-et is friss példányra cseréljük, + // hogy minden, közvetlenül ezt figyelő UI (appbar, drawer) azonnal + // lássa a változást, ne csak a projekt-választó lista. + activeProject.value = stillThere; + } + } Future> getStats(int projectId) => AppDatabase.instance.getProjectStats(projectId); Future archiveProject(int id) async { + await _loadProjects(); // friss állapot a döntéshez + final target = projects.firstWhereOrNull((p) => p.id == id); + if (target == null) return; + + // Sose maradjon nulla aktív HELYI projekt — mindig kell legyen + // legalább egy, bejelentkezés nélkül is használható projekt. + if (target.isLocalOnly) { + final otherLocal = projects.where((p) => + p.isLocalOnly && p.id != id && p.status == ProjectStatus.active); + if (otherLocal.isEmpty) { + AppLogger.event('project_archive_blocked_last_local', '', { + 'project_id': id, + 'user_id': Supabase.instance.client.auth.currentUser?.id, + 'device_name': DeviceIdentityService.to.deviceLabel.value, + 'device_id': DeviceIdentityService.to.deviceId, + }); + throw ProjectArchiveBlockedException( + 'Ez az utolsó helyi projekt — legalább egynek meg kell ' + 'maradnia, hogy bejelentkezés nélkül is legyen elérhető ' + 'projekt.'); + } + } + await AppDatabase.instance.archiveProject(id); + AppLogger.event('project_archived', '', { + 'project_id': id, + 'user_id': Supabase.instance.client.auth.currentUser?.id, + 'device_name': DeviceIdentityService.to.deviceLabel.value, + 'device_id': DeviceIdentityService.to.deviceId, + }); + await _loadProjects(); + if (activeProject.value?.id == id) { - activeProject.value = projects.isNotEmpty - ? projects.firstWhereOrNull((p) => p.id != id) - : null; + final fallback = projects.firstWhereOrNull((p) => p.isLocalOnly) ?? + projects.firstWhereOrNull((p) => p.id != id); + if (fallback != null) { + try { + await setActiveProject(fallback); + } catch (_) { + activeProject.value = null; + } + } else { + activeProject.value = null; + } } } diff --git a/lib/services/ts_sync_service.dart b/lib/services/ts_sync_service.dart index ca499e1..7f1c4ae 100644 --- a/lib/services/ts_sync_service.dart +++ b/lib/services/ts_sync_service.dart @@ -7,6 +7,7 @@ import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:terepi_seged/services/app_logger.dart'; import 'package:terepi_seged/services/contact_service.dart'; import 'package:terepi_seged/services/device_identity_service.dart'; +import 'package:terepi_seged/services/project_service.dart'; import 'package:terepi_seged/services/stakeout_sync_service.dart'; import 'app_database.dart'; @@ -134,8 +135,22 @@ class TsSyncService extends GetxService { .select() .eq('is_member', true); + final remoteUuids = {}; for (final row in rows) { - await _db.upsertProjectFromRemote(Map.from(row)); + final map = Map.from(row); + remoteUuids.add(map['id'] as String); + await _db.upsertProjectFromRemote(map); + } + + // Ami tartósan hiányzik erről a listáról (törölve vagy kikerültünk a + // tagságból), azt a helyi gyerek-adatokkal együtt eltávolítjuk. + await _db.reconcileMissingProjects(remoteUuids); + + // A ProjectService saját, memóriában tartott listája nem tud + // magától a helyi törlésről — enélkül a projekt-választóban addig + // ottmaradna, amíg valaki újra nem indítja az appot. + if (Get.isRegistered()) { + await ProjectService.to.reloadProjects(); } } diff --git a/lib/widgets/project/project_picker_view.dart b/lib/widgets/project/project_picker_view.dart index ad7c43b..8d10eff 100644 --- a/lib/widgets/project/project_picker_view.dart +++ b/lib/widgets/project/project_picker_view.dart @@ -218,9 +218,16 @@ class _ProjectTileState extends State<_ProjectTile> { TextButton(onPressed: Get.back, child: const Text('Mégse')), FilledButton( style: FilledButton.styleFrom(backgroundColor: Colors.orange), - onPressed: () { - Get.back(); - ProjectService.to.archiveProject(widget.project.id!); + onPressed: () async { + try { + ProjectService.to.archiveProject(widget.project.id!); + Get.back(); + } catch (e) { + Get.snackbar('Nem archiválható', e.toString(), + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white); + } }, child: const Text('Archiválás'), ),