memberMapper = $memberMapper; $this->addressMapper = $addressMapper; $this->phoneMapper = $phoneMapper; $this->emailMapper = $emailMapper; $this->familyMapper = $familyMapper; $this->stufeHistoryMapper = $stufeHistoryMapper; $this->auditService = $auditService; $this->logger = $logger; $this->encryptionService = $encryptionService; $this->db = $db; } /** * Reveal decrypted allergien for every non-deleted member. * * Caller must be authorized (admin-only enforced by the middleware). * The caller is audited — field + count, never the content. * * @param string $userId ID of the user triggering the reveal (for audit) * @return array Map of memberId → plaintext or null * @throws Exception * @throws \RuntimeException if EncryptionService is unavailable */ public function revealAllAllergies(string $userId): array { if ($this->encryptionService === null) { throw new \RuntimeException('EncryptionService ist nicht verfuegbar.'); } $members = $this->memberMapper->findAll(); $map = []; $counted = 0; foreach ($members as $m) { $ct = $m->getAllergienEncrypted(); if ($ct === null || $ct === '') { $map[$m->getId()] = null; continue; } $plain = $this->encryptionService->decrypt($ct); $map[$m->getId()] = $plain; if ($plain !== null && $plain !== '') { $counted++; } } $this->logger->info('Allergien-Bulk-Reveal durch Admin', [ 'app' => 'mitgliederverwaltung', 'user' => $userId, 'members_total' => count($members), 'members_with_allergien' => $counted, ]); return $map; } // ── Create ─────────────────────────────────────────────────────── /** * Create a new member with optional sub-entities. * * @param array $data Member fields * @param array $addresses Array of address data arrays * @param array $phones Array of phone data arrays * @param array $emails Array of email data arrays * @return array Member with loaded sub-entities * @throws ValidationException * @throws DuplicateMemberException * @throws Exception */ public function create( array $data, array $addresses = [], array $phones = [], array $emails = [] ): array { $this->validateRequiredFields($data); $this->validateDates($data); $this->checkForDuplicate($data); $now = (new DateTime())->format('Y-m-d H:i:s'); $member = new Member(); $member->setVorname(trim($data['vorname'])); $member->setNachname(trim($data['nachname'])); $member->setGeburtsdatum($data['geburtsdatum'] ?? null); $member->setGeschlecht($data['geschlecht'] ?? null); $member->setRolle($data['rolle'] ?? 'mitglied'); $member->setStufeId($data['stufeId'] ?? null); $member->setEintritt($data['eintritt']); $member->setAustritt($data['austritt'] ?? null); $member->setStatus($data['status'] ?? 'aktiv'); $member->setAllergienEncrypted($data['allergienEncrypted'] ?? null); $member->setNotizen($data['notizen'] ?? null); $member->setZusatzNotizen($data['zusatzNotizen'] ?? null); $member->setKvTyp($data['kvTyp'] ?? null); $member->setKvName($data['kvName'] ?? null); $member->setFamilyId($data['familyId'] ?? null); $member->setFrozenFeeRate($data['frozenFeeRate'] ?? null); $member->setCalendarEventUri($data['calendarEventUri'] ?? null); $member->setContactVcardUri($data['contactVcardUri'] ?? null); $member->setJuleicaNummer($data['juleicaNummer'] ?? null); $member->setJuleicaAblaufdatum($data['juleicaAblaufdatum'] ?? null); $member->setCreatedAt($now); $member->setUpdatedAt($now); $this->db->beginTransaction(); try { /** @var Member $member */ $member = $this->memberMapper->insert($member); $savedAddresses = $this->saveAddresses($member->getId(), $addresses); $savedPhones = $this->savePhones($member->getId(), $phones); $savedEmails = $this->saveEmails($member->getId(), $emails); $this->db->commit(); } catch (\Exception $e) { $this->db->rollback(); throw $e; } $this->auditService->logCreate($member->jsonSerialize(), 'member', $member->getId()); return $this->buildMemberResponse($member, $savedAddresses, $savedPhones, $savedEmails); } // ── Read ───────────────────────────────────────────────────────── /** * Find a member by ID with all sub-entities loaded. * * @throws DoesNotExistException * @throws MultipleObjectsReturnedException * @throws Exception */ public function find(int $id): array { $member = $this->memberMapper->findById($id); $addresses = $this->addressMapper->findByMemberId($id); $phones = $this->phoneMapper->findByMemberId($id); $emails = $this->emailMapper->findByMemberId($id); return $this->buildMemberResponse($member, $addresses, $phones, $emails); } /** * Find all members with optional pagination. * * Uses a single query with LEFT JOINs to avoid the N+1 problem. * * @return array[] * @throws Exception */ public function findAll(?int $limit = null, ?int $offset = null): array { return $this->memberMapper->findAllWithRelations($limit, $offset); } /** * Find members by family ID. * * Uses a single query with LEFT JOINs to avoid the N+1 problem. * * @return array[] * @throws Exception */ public function findByFamily(int $familyId): array { return $this->memberMapper->findByFamilyWithRelations($familyId); } /** * Find members by status. * * Uses a single query with LEFT JOINs to avoid the N+1 problem. * * @return array[] * @throws Exception */ public function findByStatus(string $status): array { return $this->memberMapper->findByStatusWithRelations($status); } /** * Search members by name. * * Uses a single query with LEFT JOINs to avoid the N+1 problem. * * @return array[] * @throws Exception */ public function search(string $query): array { return $this->memberMapper->searchWithRelations($query); } /** * Find members with a birthday in the current month. * * Uses a single query with LEFT JOINs to avoid the N+1 problem. * * Part of Issue #34. * * @return array[] * @throws Exception */ public function findByBirthdayThisMonth(): array { $currentMonth = (int)(new DateTime())->format('m'); return $this->memberMapper->findByBirthdayMonthWithRelations($currentMonth); } /** * Find members with unpaid fee records for the current year. * * Uses a single query with LEFT JOINs to avoid the N+1 problem. * * Part of Issue #34. * * @return array[] * @throws Exception */ public function findWithUnpaidFees(): array { $currentYear = (int)(new DateTime())->format('Y'); return $this->memberMapper->findWithUnpaidFeesWithRelations($currentYear); } /** * Find members matching a combination of filters (additive/AND logic). * * Uses a single query with LEFT JOINs to avoid the N+1 problem. * * @return array[] * @throws Exception */ public function findFiltered( ?string $status = null, ?string $rolle = null, bool $birthdayThisMonth = false, bool $unpaidFees = false ): array { return $this->memberMapper->findFilteredWithRelations($status, $rolle, $birthdayThisMonth, $unpaidFees); } /** * Full-text search across member names, addresses, and notes. * Returns results with match context for display in search dropdown. * * Uses a single query with LEFT JOINs to avoid the N+1 problem. * * Part of Issue #33. * * @return array[] Members with sub-entities and matchContext field * @throws Exception */ public function fullTextSearch(string $query, int $limit = 20): array { $rawResults = $this->memberMapper->fullTextSearchWithRelations($query, $limit); $lowQuery = mb_strtolower($query); return array_map(function (array $row) use ($lowQuery): array { // buildMatchContext expects an Address[] — the raw result // stores addresses as arrays. Build minimal Address objects // so the existing logic works without rewriting the method. $addresses = array_map( fn(array $a): Address => $this->arrayToAddress($a), $row['addresses'] ?? [] ); // Determine match context — which field matched $row['matchContext'] = $this->buildMatchContext( $this->arrayToMember($row), $addresses, $lowQuery ); return $row; }, $rawResults); } /** * Convert an associative array (from fetchWithRelations) to a Member entity. */ private function arrayToMember(array $row): Member { $m = new Member(); $m->setId($row['id']); $m->setVorname($row['vorname']); $m->setNachname($row['nachname']); $m->setGeburtsdatum($row['geburtsdatum']); $m->setGeschlecht($row['geschlecht']); $m->setRolle($row['rolle']); $m->setStufeId($row['stufeId']); $m->setEintritt($row['eintritt']); $m->setAustritt($row['austritt']); $m->setStatus($row['status']); $m->setAllergienEncrypted($row['allergienEncrypted']); $m->setNotizen($row['notizen']); $m->setZusatzNotizen($row['zusatzNotizen']); $m->setKvTyp($row['kvTyp']); $m->setKvName($row['kvName']); $m->setFamilyId($row['familyId']); $m->setFrozenFeeRate($row['frozenFeeRate']); $m->setCalendarEventUri($row['calendarEventUri']); $m->setContactVcardUri($row['contactVcardUri']); $m->setEinwilligungDatum($row['einwilligungDatum']); $m->setJuleicaNummer($row['juleicaNummer']); $m->setJuleicaAblaufdatum($row['juleicaAblaufdatum']); return $m; } /** * Convert an associative array (from fetchWithRelations) to an Address entity. */ private function arrayToAddress(array $row): Address { $a = new Address(); $a->setId($row['id']); $a->setMemberId($row['memberId']); $a->setLabel($row['label']); $a->setStrasse($row['strasse']); $a->setPlz($row['plz']); $a->setOrt($row['ort']); $a->setLand($row['land']); $a->setIsPrimary($row['isPrimary']); return $a; } /** * Build a human-readable match context string. * * @param Member $member * @param Address[] $addresses * @param string $lowQuery Lowercased search query * @return string */ private function buildMatchContext(Member $member, array $addresses, string $lowQuery): string { // Check name match if ( str_contains(mb_strtolower($member->getVorname()), $lowQuery) || str_contains(mb_strtolower($member->getNachname()), $lowQuery) ) { return 'Name: ' . $member->getVorname() . ' ' . $member->getNachname(); } // Check address match foreach ($addresses as $address) { if ( str_contains(mb_strtolower($address->getStrasse()), $lowQuery) || str_contains(mb_strtolower($address->getPlz()), $lowQuery) || str_contains(mb_strtolower($address->getOrt()), $lowQuery) ) { return 'Adresse: ' . $address->getStrasse() . ', ' . $address->getPlz() . ' ' . $address->getOrt(); } } // Check notes match if ($member->getNotizen() !== null && str_contains(mb_strtolower($member->getNotizen()), $lowQuery)) { $pos = mb_stripos($member->getNotizen(), $lowQuery); $start = max(0, $pos - 20); $excerpt = mb_substr($member->getNotizen(), $start, 60); return 'Notizen: ...' . $excerpt . '...'; } if ($member->getZusatzNotizen() !== null && str_contains(mb_strtolower($member->getZusatzNotizen()), $lowQuery)) { return 'Zusatz-Notizen'; } return ''; } /** * Count all non-deleted members. * * @throws Exception */ public function countAll(): int { return $this->memberMapper->countAll(); } // ── Update ─────────────────────────────────────────────────────── /** * Update an existing member. * * @param int $id Member ID * @param array $data Fields to update * @return array Updated member with sub-entities * @throws DoesNotExistException * @throws MultipleObjectsReturnedException * @throws ValidationException * @throws Exception */ public function update(int $id, array $data): array { $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 = [ 'vorname', 'nachname', 'geburtsdatum', 'geschlecht', 'rolle', 'stufeId', 'eintritt', 'austritt', 'status', 'allergienEncrypted', 'notizen', 'zusatzNotizen', 'kvTyp', 'kvName', 'familyId', 'frozenFeeRate', 'calendarEventUri', 'contactVcardUri', 'einwilligungDatum', 'juleicaNummer', 'juleicaAblaufdatum', ]; $data = array_intersect_key($data, array_flip($editableFields)); if (isset($data['vorname']) || isset($data['nachname']) || isset($data['geburtsdatum']) || isset($data['eintritt'])) { $merged = array_merge($member->jsonSerialize(), $data); $this->validateRequiredFields($merged); $this->validateDates($merged); } if (isset($data['vorname'])) { $member->setVorname(trim($data['vorname'])); } if (isset($data['nachname'])) { $member->setNachname(trim($data['nachname'])); } if (array_key_exists('geburtsdatum', $data)) { $member->setGeburtsdatum($data['geburtsdatum']); } if (array_key_exists('geschlecht', $data)) { $member->setGeschlecht($data['geschlecht']); } if (isset($data['rolle'])) { $member->setRolle($data['rolle']); } if (array_key_exists('stufeId', $data)) { $member->setStufeId($data['stufeId']); } if (array_key_exists('eintritt', $data)) { $member->setEintritt($data['eintritt']); } if (array_key_exists('austritt', $data)) { $member->setAustritt($data['austritt']); } if (isset($data['status'])) { $member->setStatus($data['status']); } if (array_key_exists('allergienEncrypted', $data)) { $member->setAllergienEncrypted($data['allergienEncrypted']); } if (array_key_exists('notizen', $data)) { $member->setNotizen($data['notizen']); } if (array_key_exists('zusatzNotizen', $data)) { $member->setZusatzNotizen($data['zusatzNotizen']); } if (array_key_exists('kvTyp', $data)) { $member->setKvTyp($data['kvTyp']); } if (array_key_exists('kvName', $data)) { $member->setKvName($data['kvName']); } if (array_key_exists('familyId', $data)) { $member->setFamilyId($data['familyId']); } if (array_key_exists('frozenFeeRate', $data)) { $member->setFrozenFeeRate($data['frozenFeeRate']); } if (array_key_exists('calendarEventUri', $data)) { $member->setCalendarEventUri($data['calendarEventUri']); } if (array_key_exists('contactVcardUri', $data)) { $member->setContactVcardUri($data['contactVcardUri']); } if (array_key_exists('einwilligungDatum', $data)) { $member->setEinwilligungDatum($data['einwilligungDatum']); } if (array_key_exists('juleicaNummer', $data)) { $member->setJuleicaNummer($data['juleicaNummer']); } if (array_key_exists('juleicaAblaufdatum', $data)) { $member->setJuleicaAblaufdatum($data['juleicaAblaufdatum']); } $member->setUpdatedAt((new DateTime())->format('Y-m-d H:i:s')); $this->db->beginTransaction(); try { /** @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->db->commit(); } catch (\Exception $e) { $this->db->rollback(); throw $e; } $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) ──────────────────────────────────────────────── /** * Soft-delete a member: set status to geloescht, hard-delete sensitive data. * * Retained fields: * - Vorname, Nachname, Geburtsdatum, Eintritt, Austritt * - Mitgliedsdauer (computed), Stufe history, fee history * * Hard-deleted immediately: * - Family banking reference (cleared, family banking data untouched) * - Allergien (encrypted field set to NULL) * - Krankenversicherung (kv_typ, kv_name set to NULL) * - Notizen, Zusaetzliche Notizen (set to NULL) * - All addresses (deleted from oc_mv_addresses) * - All phone numbers (deleted from oc_mv_phones) * - All email addresses (deleted from oc_mv_emails) * * Part of Issue #61. * * @throws DoesNotExistException * @throws MultipleObjectsReturnedException * @throws Exception */ public function softDelete(int $id): void { $member = $this->memberMapper->findById($id); $now = (new DateTime())->format('Y-m-d H:i:s'); // Set austritt to today if not already set if ($member->getAustritt() === null) { $member->setAustritt((new DateTime())->format('Y-m-d')); } // Set soft-delete status $member->setStatus('geloescht'); $member->setDeletedAt($now); $member->setUpdatedAt($now); // Hard-delete sensitive fields on the member record $member->setAllergienEncrypted(null); $member->setNotizen(null); $member->setZusatzNotizen(null); $member->setKvTyp(null); $member->setKvName(null); $member->setFrozenFeeRate(null); // Clear family reference (family banking data remains on the family) $member->setFamilyId(null); // Clear integration references $member->setCalendarEventUri(null); $member->setContactVcardUri(null); $this->db->beginTransaction(); try { $this->memberMapper->update($member); // Hard-delete sub-entities (addresses, phones, emails) $this->addressMapper->deleteByMemberId($id); $this->phoneMapper->deleteByMemberId($id); $this->emailMapper->deleteByMemberId($id); $this->db->commit(); } catch (\Exception $e) { $this->db->rollback(); throw $e; } // Audit log the soft-delete $this->auditService->logSoftDelete('member', $id); // Log hard-deleted sensitive data as [geloescht] in audit $sensitiveFields = [ 'allergienEncrypted' => '[geloescht]', 'kvTyp' => '[geloescht]', 'kvName' => '[geloescht]', 'notizen' => '[geloescht]', 'zusatzNotizen' => '[geloescht]', 'adressen' => '[geloescht]', 'telefonnummern' => '[geloescht]', 'emails' => '[geloescht]', ]; $this->auditService->logCreate($sensitiveFields, 'soft-delete-purge', $id); $this->logger->info('Member soft-deleted with sensitive data purged', [ 'memberId' => $id, 'app' => 'mitgliederverwaltung', ]); } /** * Legacy delete method — delegates to softDelete. * * @throws DoesNotExistException * @throws MultipleObjectsReturnedException * @throws Exception */ public function delete(int $id): void { $this->softDelete($id); } // ── Archive (soft-deleted members) ─────────────────────────────── /** * Find all soft-deleted members for the archive view. * * Returns only retained fields: Name, Geburtsdatum, Eintritt, Austritt. * * Part of Issue #61. * * @return array[] * @throws Exception */ public function findArchived(?int $limit = null, ?int $offset = null): array { $members = $this->memberMapper->findArchived($limit, $offset); return array_values(array_map(function (Member $member) { // Only return retained fields return [ 'id' => $member->getId(), 'vorname' => $member->getVorname(), 'nachname' => $member->getNachname(), 'geburtsdatum' => $member->getGeburtsdatum(), 'eintritt' => $member->getEintritt(), 'austritt' => $member->getAustritt(), 'deletedAt' => $member->getDeletedAt(), 'mitgliedsdauer' => $this->calculateMitgliedsdauer($member), ]; }, $members)); } /** * Count all soft-deleted (archived) members. * * Part of Issue #201: uses a single SQL COUNT query via the mapper * instead of loading all members into memory. * * @throws Exception */ public function countArchived(): int { return $this->memberMapper->countArchived(); } /** * Calculate membership duration in years and months. */ private function calculateMitgliedsdauer(Member $member): string { try { $eintritt = new DateTime($member->getEintritt()); $end = $member->getAustritt() ? new DateTime($member->getAustritt()) : new DateTime(); $diff = $eintritt->diff($end); $years = $diff->y; $months = $diff->m; if ($years > 0 && $months > 0) { return "{$years} Jahre, {$months} Monate"; } elseif ($years > 0) { return "{$years} Jahre"; } else { return "{$months} Monate"; } } catch (\Exception $e) { return '–'; } } // ── Sub-entity management: Addresses ───────────────────────────── /** * Add an address to a member. * * @throws Exception */ public function addAddress(int $memberId, array $data): Address { $address = new Address(); $address->setMemberId($memberId); $address->setLabel($data['label'] ?? null); $address->setStrasse(trim($data['strasse'])); $address->setPlz(trim($data['plz'])); $address->setOrt(trim($data['ort'])); $address->setLand($data['land'] ?? 'Deutschland'); $address->setIsPrimary($data['isPrimary'] ?? false); /** @var Address $address */ return $this->addressMapper->insert($address); } /** * Update an existing address. * * @throws DoesNotExistException * @throws MultipleObjectsReturnedException * @throws Exception */ public function updateAddress(int $addressId, array $data): Address { /** @var Address $address */ $address = $this->addressMapper->find($addressId); if (isset($data['label'])) { $address->setLabel($data['label']); } if (isset($data['strasse'])) { $address->setStrasse(trim($data['strasse'])); } if (isset($data['plz'])) { $address->setPlz(trim($data['plz'])); } if (isset($data['ort'])) { $address->setOrt(trim($data['ort'])); } if (isset($data['land'])) { $address->setLand($data['land']); } if (array_key_exists('isPrimary', $data)) { $address->setIsPrimary($data['isPrimary']); } /** @var Address $address */ return $this->addressMapper->update($address); } /** * Delete an address. * * @throws DoesNotExistException * @throws MultipleObjectsReturnedException * @throws Exception */ public function deleteAddress(int $addressId): void { $address = $this->addressMapper->find($addressId); $this->addressMapper->delete($address); } // ── Sub-entity management: Phones ──────────────────────────────── /** * Add a phone number to a member. * Validates and normalizes to E.164 format. * * @throws ValidationException * @throws Exception */ public function addPhone(int $memberId, array $data): Phone { $raw = trim($data['numberE164'] ?? ''); $normalized = PhoneValidator::validateAndNormalize($raw); if ($normalized === null) { throw new ValidationException(PhoneValidator::getErrorMessage($raw)); } $phone = new Phone(); $phone->setMemberId($memberId); $phone->setLabel($data['label'] ?? null); $phone->setNumberE164($normalized); /** @var Phone $phone */ return $this->phoneMapper->insert($phone); } /** * Update an existing phone number. * Validates and normalizes to E.164 format if number is changed. * * @throws DoesNotExistException * @throws MultipleObjectsReturnedException * @throws ValidationException * @throws Exception */ public function updatePhone(int $phoneId, array $data): Phone { /** @var Phone $phone */ $phone = $this->phoneMapper->find($phoneId); if (isset($data['label'])) { $phone->setLabel($data['label']); } if (isset($data['numberE164'])) { $raw = trim($data['numberE164']); $normalized = PhoneValidator::validateAndNormalize($raw); if ($normalized === null) { throw new ValidationException(PhoneValidator::getErrorMessage($raw)); } $phone->setNumberE164($normalized); } /** @var Phone $phone */ return $this->phoneMapper->update($phone); } /** * Delete a phone number. * * @throws DoesNotExistException * @throws MultipleObjectsReturnedException * @throws Exception */ public function deletePhone(int $phoneId): void { $phone = $this->phoneMapper->find($phoneId); $this->phoneMapper->delete($phone); } // ── Sub-entity management: Emails ──────────────────────────────── /** * Add an email address to a member. * * @throws Exception */ public function addEmail(int $memberId, array $data): Email { $email = new Email(); $email->setMemberId($memberId); $email->setLabel($data['label'] ?? null); $email->setEmail(trim($data['email'])); /** @var Email $email */ return $this->emailMapper->insert($email); } /** * Update an existing email address. * * @throws DoesNotExistException * @throws MultipleObjectsReturnedException * @throws Exception */ public function updateEmail(int $emailId, array $data): Email { /** @var Email $email */ $email = $this->emailMapper->find($emailId); if (isset($data['label'])) { $email->setLabel($data['label']); } if (isset($data['email'])) { $email->setEmail(trim($data['email'])); } /** @var Email $email */ return $this->emailMapper->update($email); } /** * Delete an email address. * * @throws DoesNotExistException * @throws MultipleObjectsReturnedException * @throws Exception */ public function deleteEmail(int $emailId): void { $email = $this->emailMapper->find($emailId); $this->emailMapper->delete($email); } // ── Validation helpers ─────────────────────────────────────────── /** * Validate that all required fields are present and non-empty. * Geburtsdatum is optional (Issue #162). * * @throws ValidationException */ private function validateRequiredFields(array $data): void { $required = ['vorname', 'nachname', 'eintritt']; $missing = []; foreach ($required as $field) { if (!isset($data[$field]) || (is_string($data[$field]) && trim($data[$field]) === '')) { $missing[] = $field; } } if (!empty($missing)) { throw new ValidationException( 'Missing required fields: ' . implode(', ', $missing) ); } } /** * Validate date plausibility. * * - Birthday must not be in the future * - Birthday must not be more than 120 years ago * - Eintritt must not be before birthday * * @throws ValidationException */ private function validateDates(array $data): void { $now = new DateTime(); if (!empty($data['geburtsdatum'])) { $birthday = new DateTime($data['geburtsdatum']); if ($birthday > $now) { throw new ValidationException('Geburtsdatum darf nicht in der Zukunft liegen.'); } $maxAge = (clone $now)->modify('-120 years'); if ($birthday < $maxAge) { throw new ValidationException('Geburtsdatum ist unplausibel (mehr als 120 Jahre her).'); } if (isset($data['eintritt'])) { $eintritt = new DateTime($data['eintritt']); if ($eintritt < $birthday) { throw new ValidationException('Eintrittsdatum darf nicht vor dem Geburtsdatum liegen.'); } } } } /** * Check for a duplicate member (matching vorname + nachname + geburtsdatum). * When geburtsdatum is missing, skip duplicate check (Issue #162). * * @throws DuplicateMemberException * @throws Exception */ private function checkForDuplicate(array $data): void { // Skip duplicate check if birthday is missing — cannot reliably // detect duplicates with name alone if (empty($data['geburtsdatum'])) { return; } $existing = $this->memberMapper->search($data['nachname']); foreach ($existing as $member) { if ( mb_strtolower($member->getVorname()) === mb_strtolower(trim($data['vorname'])) && mb_strtolower($member->getNachname()) === mb_strtolower(trim($data['nachname'])) && $member->getGeburtsdatum() === $data['geburtsdatum'] ) { throw new DuplicateMemberException( 'Ein Mitglied mit gleichem Vor-/Nachname und Geburtsdatum existiert bereits (ID: ' . $member->getId() . ').' ); } } } // ── Response builder ───────────────────────────────────────────── /** * Build a response array containing member data with sub-entities. */ private function buildMemberResponse(Member $member, array $addresses, array $phones, array $emails): array { $result = $member->jsonSerialize(); $result['addresses'] = array_map(fn(Address $a) => $a->jsonSerialize(), $addresses); $result['phones'] = array_map(fn(Phone $p) => $p->jsonSerialize(), $phones); $result['emails'] = array_map(fn(Email $e) => $e->jsonSerialize(), $emails); return $result; } /** * Save a batch of addresses for a member. * * @return Address[] * @throws Exception */ private function saveAddresses(int $memberId, array $addressesData): array { $saved = []; foreach ($addressesData as $data) { $saved[] = $this->addAddress($memberId, $data); } return $saved; } /** * Save a batch of phone numbers for a member. * * @return Phone[] * @throws Exception */ private function savePhones(int $memberId, array $phonesData): array { $saved = []; foreach ($phonesData as $data) { $saved[] = $this->addPhone($memberId, $data); } return $saved; } /** * Save a batch of email addresses for a member. * * @return Email[] * @throws Exception */ private function saveEmails(int $memberId, array $emailsData): array { $saved = []; foreach ($emailsData as $data) { $saved[] = $this->addEmail($memberId, $data); } return $saved; } }