Files
Mitgliederverwaltung/lib/Service/MemberService.php
T
shahondin1624 bfb57b4e1e refactor(MemberService): extract generic syncSubEntities() method
Factor out the duplicated sync logic from syncAddresses(), syncPhones(),
and syncEmails() into a single generic syncSubEntities() method that
accepts callables for fetch/update/create/delete operations. Each
specialized sync method is now a thin ~10-line wrapper.

(Closes #204)
2026-04-30 08:12:35 +02:00

1156 lines
40 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Service;
use DateTime;
use OCA\Mitgliederverwaltung\Db\Address;
use OCA\Mitgliederverwaltung\Db\AddressMapper;
use OCA\Mitgliederverwaltung\Db\Email;
use OCA\Mitgliederverwaltung\Db\EmailMapper;
use OCA\Mitgliederverwaltung\Db\FamilyMapper;
use OCA\Mitgliederverwaltung\Db\Member;
use OCA\Mitgliederverwaltung\Db\MemberMapper;
use OCA\Mitgliederverwaltung\Db\Phone;
use OCA\Mitgliederverwaltung\Db\PhoneMapper;
use OCA\Mitgliederverwaltung\Db\StufeHistoryMapper;
use OCA\Mitgliederverwaltung\Validation\PhoneValidator;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\DB\Exception;
use OCP\IDBConnection;
use Psr\Log\LoggerInterface;
/**
* Service layer for member CRUD operations with validation and business logic.
*
* Part of Issues #21, #61.
*/
class MemberService {
private MemberMapper $memberMapper;
private AddressMapper $addressMapper;
private PhoneMapper $phoneMapper;
private EmailMapper $emailMapper;
private FamilyMapper $familyMapper;
private StufeHistoryMapper $stufeHistoryMapper;
private AuditService $auditService;
private LoggerInterface $logger;
private ?EncryptionService $encryptionService;
private IDBConnection $db;
public function __construct(
MemberMapper $memberMapper,
AddressMapper $addressMapper,
PhoneMapper $phoneMapper,
EmailMapper $emailMapper,
FamilyMapper $familyMapper,
StufeHistoryMapper $stufeHistoryMapper,
AuditService $auditService,
LoggerInterface $logger,
?EncryptionService $encryptionService,
IDBConnection $db
) {
$this->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<int,string|null> 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);
}
/**
* Generic sub-entity sync: update existing, create new, delete removed.
*
* @param int $memberId
* @param array[] $incoming Payload arrays (each may contain an 'id' key)
* @param callable(): list<object> $fetchExisting Fetch current DB entities for this member
* @param callable(object): int $getId Extract entity ID
* @param callable(int, array): void $updateEntity Update an existing entity by ID
* @param callable(int, array): void $createEntity Create a new entity for a member
* @param callable(int): void $deleteEntity Delete an entity by ID
* @throws Exception
*/
private function syncSubEntities(
int $memberId,
array $incoming,
callable $fetchExisting,
callable $getId,
callable $updateEntity,
callable $createEntity,
callable $deleteEntity
): void {
$existing = $fetchExisting();
$existingById = [];
foreach ($existing as $entity) {
$existingById[$getId($entity)] = $entity;
}
$keptIds = [];
foreach ($incoming as $item) {
$id = isset($item['id']) ? (int)$item['id'] : 0;
if ($id > 0 && isset($existingById[$id])) {
$updateEntity($id, $item);
$keptIds[$id] = true;
} else {
$createEntity($memberId, $item);
}
}
foreach (array_keys($existingById) as $id) {
if (!isset($keptIds[$id])) {
$deleteEntity($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 {
$this->syncSubEntities(
$memberId,
$incoming,
fn() => $this->addressMapper->findByMemberId($memberId),
fn($e) => $e->getId(),
fn($id, $data) => $this->updateAddress($id, $data),
fn($mid, $data) => $this->addAddress($mid, $data),
fn($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 {
$this->syncSubEntities(
$memberId,
$incoming,
fn() => $this->phoneMapper->findByMemberId($memberId),
fn($e) => $e->getId(),
fn($id, $data) => $this->updatePhone($id, $data),
fn($mid, $data) => $this->addPhone($mid, $data),
fn($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 {
$this->syncSubEntities(
$memberId,
$incoming,
fn() => $this->emailMapper->findByMemberId($memberId),
fn($e) => $e->getId(),
fn($id, $data) => $this->updateEmail($id, $data),
fn($mid, $data) => $this->addEmail($mid, $data),
fn($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;
}
}