bfb57b4e1e
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)
1156 lines
40 KiB
PHP
1156 lines
40 KiB
PHP
<?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;
|
||
}
|
||
}
|