diff --git a/appinfo/info.xml b/appinfo/info.xml index f43a583..7b2f09d 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ Mitgliederverwaltung Mitgliederverwaltung für Pfadfindervereine - 0.2.8 + 0.2.9 agpl shahondin1624 Mitgliederverwaltung diff --git a/lib/Service/MemberService.php b/lib/Service/MemberService.php index 3b54bf4..111d1f2 100644 --- a/lib/Service/MemberService.php +++ b/lib/Service/MemberService.php @@ -370,6 +370,10 @@ class MemberService { $member = $this->memberMapper->findById($id); $oldData = $member->jsonSerialize(); + // Preserve the raw payload so we can still see addresses/phones/emails + // after filtering the member-column data below. + $rawData = $data; + // Filter data to only known editable fields to prevent pollution // from read-only/system fields (createdAt, updatedAt, deletedAt, etc.) $editableFields = [ @@ -456,11 +460,120 @@ class MemberService { /** @var Member $member */ $member = $this->memberMapper->update($member); + // Sync sub-entities if the caller included them in the payload. + // Absent keys (undefined) leave existing sub-entities untouched. + if (array_key_exists('addresses', $rawData) && is_array($rawData['addresses'])) { + $this->syncAddresses($id, $rawData['addresses']); + } + if (array_key_exists('phones', $rawData) && is_array($rawData['phones'])) { + $this->syncPhones($id, $rawData['phones']); + } + if (array_key_exists('emails', $rawData) && is_array($rawData['emails'])) { + $this->syncEmails($id, $rawData['emails']); + } + $this->auditService->logUpdate($oldData, $member->jsonSerialize(), 'member', $id); return $this->find($id); } + /** + * Sync a member's addresses against an incoming array. + * Rules: + * - Items with an existing id ∈ current DB set → updated + * - Items without id (or with unknown id) → created + * - Existing DB items whose id is not referenced → deleted + * + * @param array[] $incoming + * @throws Exception + */ + private function syncAddresses(int $memberId, array $incoming): void { + $existing = $this->addressMapper->findByMemberId($memberId); + $existingById = []; + foreach ($existing as $addr) { + $existingById[$addr->getId()] = $addr; + } + + $keptIds = []; + foreach ($incoming as $item) { + $id = isset($item['id']) ? (int)$item['id'] : 0; + if ($id > 0 && isset($existingById[$id])) { + $this->updateAddress($id, $item); + $keptIds[$id] = true; + } else { + $this->addAddress($memberId, $item); + } + } + + foreach ($existingById as $id => $addr) { + if (!isset($keptIds[$id])) { + $this->deleteAddress($id); + } + } + } + + /** + * Sync a member's phones (same rules as {@see syncAddresses}). + * + * @param array[] $incoming + * @throws Exception + */ + private function syncPhones(int $memberId, array $incoming): void { + $existing = $this->phoneMapper->findByMemberId($memberId); + $existingById = []; + foreach ($existing as $p) { + $existingById[$p->getId()] = $p; + } + + $keptIds = []; + foreach ($incoming as $item) { + $id = isset($item['id']) ? (int)$item['id'] : 0; + if ($id > 0 && isset($existingById[$id])) { + $this->updatePhone($id, $item); + $keptIds[$id] = true; + } else { + $this->addPhone($memberId, $item); + } + } + + foreach ($existingById as $id => $p) { + if (!isset($keptIds[$id])) { + $this->deletePhone($id); + } + } + } + + /** + * Sync a member's emails (same rules as {@see syncAddresses}). + * + * @param array[] $incoming + * @throws Exception + */ + private function syncEmails(int $memberId, array $incoming): void { + $existing = $this->emailMapper->findByMemberId($memberId); + $existingById = []; + foreach ($existing as $e) { + $existingById[$e->getId()] = $e; + } + + $keptIds = []; + foreach ($incoming as $item) { + $id = isset($item['id']) ? (int)$item['id'] : 0; + if ($id > 0 && isset($existingById[$id])) { + $this->updateEmail($id, $item); + $keptIds[$id] = true; + } else { + $this->addEmail($memberId, $item); + } + } + + foreach ($existingById as $id => $e) { + if (!isset($keptIds[$id])) { + $this->deleteEmail($id); + } + } + } + // ── Delete (soft) ──────────────────────────────────────────────── /** diff --git a/lib/Service/SelfUpdateService.php b/lib/Service/SelfUpdateService.php index 78dd975..61c654e 100644 --- a/lib/Service/SelfUpdateService.php +++ b/lib/Service/SelfUpdateService.php @@ -60,13 +60,17 @@ class SelfUpdateService { 'exception' => $e, 'app' => self::APP_ID, ]); + $classification = $this->classifyNetworkError($e); return [ 'available' => false, 'currentVersion' => $currentVersion, 'latestVersion' => null, 'releaseUrl' => null, 'publishedAt' => null, - 'error' => $e->getMessage(), + 'error' => $classification['message'], + 'errorType' => $classification['type'], + 'errorDetail' => $e->getMessage(), + 'targetUrl' => self::GITEA_BASE, ]; } @@ -437,6 +441,57 @@ class SelfUpdateService { * * @return array{success: bool, output: string|null, error: string|null} */ + /** + * Classify a network exception into a user-friendly diagnostic hint. + * + * @return array{type: string, message: string} + */ + private function classifyNetworkError(\Throwable $e): array { + $raw = strtolower($e->getMessage()); + $host = parse_url(self::GITEA_BASE, PHP_URL_HOST) ?: 'Gitea-Server'; + + if (str_contains($raw, 'could not resolve host') || str_contains($raw, 'name resolution') || str_contains($raw, 'getaddrinfo')) { + return [ + 'type' => 'dns', + 'message' => "DNS-Aufloesung fuer {$host} fehlgeschlagen. Pruefe DNS des Servers (z. B. Mobilfunk-/Hotspot-DNS blockiert die Domain).", + ]; + } + if (str_contains($raw, 'timed out') || str_contains($raw, 'timeout') || str_contains($raw, 'operation timed out')) { + return [ + 'type' => 'timeout', + 'message' => "Zeitueberschreitung beim Verbindungsaufbau zu {$host} (Timeout: 15 s). Bei Mobilfunk/Hotspot ist die Latenz oft zu hoch.", + ]; + } + if (str_contains($raw, 'ssl') || str_contains($raw, 'certificate') || str_contains($raw, 'tls')) { + return [ + 'type' => 'tls', + 'message' => "TLS-Fehler bei {$host}. Moegliche Ursache: Captive Portal, HTTPS-Inspection oder abgelaufenes Zertifikat.", + ]; + } + if (str_contains($raw, 'connection refused') || str_contains($raw, 'could not connect') || str_contains($raw, 'no route to host') || str_contains($raw, 'network is unreachable')) { + return [ + 'type' => 'network', + 'message' => "Verbindung zu {$host} nicht moeglich. Der Server hat keinen Zugriff auf diese Domain (Firewall, Routing, IPv6-only-Mobilfunk ohne IPv4-Fallback?).", + ]; + } + if (preg_match('/\b(4\d\d|5\d\d)\b/', $raw, $m)) { + return [ + 'type' => 'http', + 'message' => "HTTP-Fehler {$m[1]} beim Abruf von {$host}.", + ]; + } + if (str_contains($raw, 'json') || str_contains($raw, 'decode')) { + return [ + 'type' => 'parse', + 'message' => "Antwort von {$host} konnte nicht als JSON gelesen werden (evtl. Login-Seite eines Captive Portals).", + ]; + } + return [ + 'type' => 'unknown', + 'message' => "Update-Pruefung fehlgeschlagen: " . $e->getMessage(), + ]; + } + private function runOccUpgrade(): array { $serverRoot = defined('OC_SERVERROOT') ? OC_SERVERROOT : '/var/www/html'; $occPath = $serverRoot . '/occ'; diff --git a/src/components/SearchBar.vue b/src/components/SearchBar.vue index ec1d169..e980206 100644 --- a/src/components/SearchBar.vue +++ b/src/components/SearchBar.vue @@ -30,6 +30,15 @@ Suche... + +
+ Suche fehlgeschlagen +
{{ error }}
+ +
+
Keine Ergebnisse für "{{ query }}" @@ -80,16 +89,19 @@ const router = useRouter() const query = ref('') const results = ref([]) const loading = ref(false) +const error = ref(null) const showResults = ref(false) const selectedIndex = ref(-1) const inputRef = ref(null) let searchTimeout = null +let inflightRequestId = 0 // ── Search logic ──────────────────────────────────────────────────── function onInput() { selectedIndex.value = -1 + error.value = null clearTimeout(searchTimeout) if (query.value.trim().length < 2) { @@ -105,22 +117,55 @@ function onInput() { async function performSearch() { if (query.value.trim().length < 2) return + const requestId = ++inflightRequestId loading.value = true + error.value = null try { const url = generateUrl('/apps/mitgliederverwaltung/api/v1/members/search') const response = await axios.get(url, { params: { q: query.value.trim(), limit: 10 }, + timeout: 10000, }) + // Drop stale responses — only the most recent request wins + if (requestId !== inflightRequestId) return results.value = response.data.data || [] } catch (err) { + if (requestId !== inflightRequestId) return console.error('Search failed:', err) results.value = [] + error.value = formatSearchError(err) } finally { - loading.value = false + if (requestId === inflightRequestId) { + loading.value = false + } } } +function formatSearchError(err) { + if (err?.code === 'ECONNABORTED' || /timeout/i.test(err?.message || '')) { + return 'Zeitüberschreitung der Anfrage (10 s). Server ist überlastet oder die Verbindung ist langsam.' + } + const status = err?.response?.status + if (status === 401 || status === 403) { + return 'Sitzung abgelaufen. Bitte die Seite neu laden und erneut anmelden.' + } + if (status === 429) { + return 'Zu viele Anfragen. Nextcloud hat die Suche vorübergehend gesperrt — kurz warten und erneut versuchen.' + } + if (status >= 500 && status < 600) { + const serverMsg = err.response?.data?.error || 'Serverfehler' + return `HTTP ${status}: ${serverMsg}` + } + if (status) { + return `HTTP ${status}: ${err.response?.data?.error || err.message}` + } + if (err?.message) { + return `Netzwerkfehler: ${err.message}` + } + return 'Unbekannter Fehler.' +} + // ── Dropdown visibility ───────────────────────────────────────────── function onFocus() { @@ -146,6 +191,7 @@ function clearQuery() { query.value = '' results.value = [] selectedIndex.value = -1 + error.value = null nextTick(() => { inputRef.value?.focus() }) @@ -270,6 +316,36 @@ function formatStatus(status) { text-align: center; } +.search-bar__error { + padding: 12px 16px; + color: var(--color-error); + background: color-mix(in srgb, var(--color-error) 10%, transparent); + font-size: 0.85em; + border-radius: var(--border-radius); + margin: 4px; +} + +.search-bar__error strong { + display: block; + margin-bottom: 4px; +} + +.search-bar__error-msg { + color: var(--color-text); + margin-bottom: 8px; + word-break: break-word; +} + +.search-bar__retry { + background: var(--color-error); + color: white; + border: none; + border-radius: var(--border-radius); + padding: 4px 10px; + cursor: pointer; + font-size: 0.85em; +} + .search-bar__results { list-style: none; margin: 0; diff --git a/src/main.js b/src/main.js index 44c27b9..7c1830c 100644 --- a/src/main.js +++ b/src/main.js @@ -31,7 +31,7 @@ app.use(router) // @nextcloud/vue v9 reads appName/appVersion via Vue's inject(), // not via webpack DefinePlugin globals. app.provide('appName', 'mitgliederverwaltung') -app.provide('appVersion', '0.2.8') +app.provide('appVersion', '0.2.9') app.mount('#mitgliederverwaltung') diff --git a/src/views/Backup.vue b/src/views/Backup.vue index 78c35ed..f7ecd03 100644 --- a/src/views/Backup.vue +++ b/src/views/Backup.vue @@ -28,12 +28,24 @@ Aktuell
+
+ Update-Check fehlgeschlagen +

{{ store.updateInfo.error }}

+
+ Technische Details +
{{ store.updateInfo.errorDetail }}
+
+ Ziel: {{ store.updateInfo.targetUrl }} +
+
+
{{ store.updateInfo.releaseBody }}
@@ -293,6 +305,11 @@ function formatTrigger(trigger) { .backup-page__update { padding: 12px 16px; background: var(--color-background-dark); border-radius: var(--border-radius-large); } .backup-page__update-status { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; } .backup-page__update-notes { font-size: 0.9em; color: var(--color-text-lighter); margin-bottom: 12px; white-space: pre-line; } +.backup-page__update-error { margin: 8px 0 12px; padding: 10px 14px; border-radius: var(--border-radius); background: color-mix(in srgb, var(--color-error) 10%, transparent); border: 1px solid color-mix(in srgb, var(--color-error) 40%, transparent); font-size: 0.9em; } +.backup-page__update-error strong { color: var(--color-error); } +.backup-page__update-error p { margin: 4px 0 8px; } +.backup-page__update-error summary { cursor: pointer; color: var(--color-text-lighter); font-size: 0.85em; } +.backup-page__update-error-detail { white-space: pre-wrap; word-break: break-word; font-size: 0.8em; margin: 4px 0; padding: 6px 8px; background: var(--color-background-dark); border-radius: var(--border-radius); } .backup-page__update-actions { display: flex; gap: 8px; } .backup-page__create { display: flex; gap: 16px; align-items: flex-end; } diff --git a/src/views/MemberDetail.vue b/src/views/MemberDetail.vue index 7b012d8..9ddc77c 100644 --- a/src/views/MemberDetail.vue +++ b/src/views/MemberDetail.vue @@ -365,6 +365,9 @@ async function save() { einwilligungDatum: d.einwilligungDatum, juleicaNummer: d.juleicaNummer, juleicaAblaufdatum: d.juleicaAblaufdatum, + addresses: d.addresses || [], + phones: d.phones || [], + emails: d.emails || [], } await store.updateMember(Number(props.id), data) await loadMember() diff --git a/tests/Unit/MemberServiceTest.php b/tests/Unit/MemberServiceTest.php index 0d2a91c..b030a0a 100644 --- a/tests/Unit/MemberServiceTest.php +++ b/tests/Unit/MemberServiceTest.php @@ -551,6 +551,136 @@ class MemberServiceTest extends TestCase { $this->assertSame('Moritz', $result['vorname']); } + public function testUpdateAddsNewAddressWhenSyncingEmptyList(): void { + $member = $this->createMember(1); + + $this->memberMapper->method('findById')->with(1)->willReturn($member); + $this->memberMapper->method('update')->willReturnArgument(0); + $this->addressMapper->method('findByMemberId')->willReturn([]); + $this->phoneMapper->method('findByMemberId')->willReturn([]); + $this->emailMapper->method('findByMemberId')->willReturn([]); + + $inserted = new Address(); + $inserted->setStrasse('Musterstr. 1'); + $inserted->setPlz('12345'); + $inserted->setOrt('Berlin'); + $inserted->setLand('Deutschland'); + $inserted->setMemberId(1); + $inserted->setIsPrimary(false); + + $this->addressMapper->expects($this->once()) + ->method('insert') + ->willReturn($inserted); + $this->addressMapper->expects($this->never())->method('update'); + $this->addressMapper->expects($this->never())->method('delete'); + + $result = $this->service->update(1, [ + 'vorname' => 'Moritz', + 'addresses' => [ + ['strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland'], + ], + ]); + + $this->assertSame('Moritz', $result['vorname']); + } + + public function testUpdateUpdatesExistingAddressById(): void { + $member = $this->createMember(1); + + $existing = new Address(); + $existing->setId(42); + $existing->setMemberId(1); + $existing->setStrasse('Alt'); + $existing->setPlz('00000'); + $existing->setOrt('Alt'); + $existing->setLand('Deutschland'); + $existing->setIsPrimary(false); + + $this->memberMapper->method('findById')->with(1)->willReturn($member); + $this->memberMapper->method('update')->willReturnArgument(0); + $this->addressMapper->method('findByMemberId')->willReturn([$existing]); + $this->phoneMapper->method('findByMemberId')->willReturn([]); + $this->emailMapper->method('findByMemberId')->willReturn([]); + + $this->addressMapper->method('find')->with(42)->willReturn($existing); + $this->addressMapper->expects($this->once()) + ->method('update') + ->willReturnArgument(0); + $this->addressMapper->expects($this->never())->method('insert'); + $this->addressMapper->expects($this->never())->method('delete'); + + $this->service->update(1, [ + 'addresses' => [ + ['id' => 42, 'strasse' => 'Neu 1', 'plz' => '12345', 'ort' => 'Neu'], + ], + ]); + } + + public function testUpdateDeletesAddressesNotInPayload(): void { + $member = $this->createMember(1); + + $keep = new Address(); + $keep->setId(42); + $keep->setMemberId(1); + $keep->setStrasse('Keep'); + $keep->setPlz('11111'); + $keep->setOrt('Keep'); + $keep->setLand('Deutschland'); + $keep->setIsPrimary(false); + + $remove = new Address(); + $remove->setId(43); + $remove->setMemberId(1); + $remove->setStrasse('Remove'); + $remove->setPlz('22222'); + $remove->setOrt('Remove'); + $remove->setLand('Deutschland'); + $remove->setIsPrimary(false); + + $this->memberMapper->method('findById')->with(1)->willReturn($member); + $this->memberMapper->method('update')->willReturnArgument(0); + $this->addressMapper->method('findByMemberId')->willReturn([$keep, $remove]); + $this->phoneMapper->method('findByMemberId')->willReturn([]); + $this->emailMapper->method('findByMemberId')->willReturn([]); + + $this->addressMapper->method('find')->willReturnMap([ + [42, $keep], + [43, $remove], + ]); + $this->addressMapper->expects($this->once())->method('update')->willReturnArgument(0); + $this->addressMapper->expects($this->once())->method('delete')->with($remove); + $this->addressMapper->expects($this->never())->method('insert'); + + $this->service->update(1, [ + 'addresses' => [ + ['id' => 42, 'strasse' => 'Keep', 'plz' => '11111', 'ort' => 'Keep'], + ], + ]); + } + + public function testUpdateWithoutSubEntitiesKeysDoesNotTouchAddresses(): void { + $member = $this->createMember(1); + + $this->memberMapper->method('findById')->with(1)->willReturn($member); + $this->memberMapper->method('update')->willReturnArgument(0); + $this->addressMapper->method('findByMemberId')->willReturn([]); + $this->phoneMapper->method('findByMemberId')->willReturn([]); + $this->emailMapper->method('findByMemberId')->willReturn([]); + + // Only mapper calls allowed: no insert/update/delete on sub-entity mappers + $this->addressMapper->expects($this->never())->method('insert'); + $this->addressMapper->expects($this->never())->method('update'); + $this->addressMapper->expects($this->never())->method('delete'); + $this->phoneMapper->expects($this->never())->method('insert'); + $this->phoneMapper->expects($this->never())->method('update'); + $this->phoneMapper->expects($this->never())->method('delete'); + $this->emailMapper->expects($this->never())->method('insert'); + $this->emailMapper->expects($this->never())->method('update'); + $this->emailMapper->expects($this->never())->method('delete'); + + $this->service->update(1, ['vorname' => 'Moritz']); + } + public function testUpdateImportedMemberSucceeds(): void { // Simulate a member created by import (has all fields set via import path) $member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01'); diff --git a/webpack.config.js b/webpack.config.js index ee1939d..13d5caa 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -41,7 +41,7 @@ module.exports = { new VueLoaderPlugin(), new webpack.DefinePlugin({ appName: JSON.stringify('mitgliederverwaltung'), - appVersion: JSON.stringify('0.2.8'), + appVersion: JSON.stringify('0.2.9'), }), new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1,