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
-
+
Pruefung fehlgeschlagen
+
+
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,