Projektkezeléssel kapcsolatos hibajavítások, projekt törlése a szerveren, bejelentkezés
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 6s
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 6s
This commit is contained in:
@@ -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<void> _migrateToV4(Database db) async {
|
||||
@@ -1406,6 +1409,11 @@ class AppDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addProjectMissingStreakColumn(Database db) async {
|
||||
await _tryExec(db,
|
||||
'ALTER TABLE projects ADD COLUMN missing_streak INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
|
||||
// Future<void> 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<void> reconcileMissingProjects(Set<String> 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<void> _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).');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> 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>()) {
|
||||
TsSyncService.to.syncNow();
|
||||
}
|
||||
@@ -227,26 +319,104 @@ class ProjectService extends GetxService {
|
||||
Future<void> 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<void> 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<void> 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<Map<String, int>> getStats(int projectId) =>
|
||||
AppDatabase.instance.getProjectStats(projectId);
|
||||
|
||||
Future<void> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = <String>{};
|
||||
for (final row in rows) {
|
||||
await _db.upsertProjectFromRemote(Map<String, dynamic>.from(row));
|
||||
final map = Map<String, dynamic>.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<ProjectService>()) {
|
||||
await ProjectService.to.reloadProjects();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user