Projektkezeléssel kapcsolatos hibajavítások, projekt törlése a szerveren, bejelentkezés
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 6s

This commit is contained in:
2026-08-09 23:18:06 +02:00
parent e1128700f0
commit fcd3971079
4 changed files with 317 additions and 37 deletions
+201 -31
View File
@@ -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;
}
}
}