Compare commits

...

13 Commits

Author SHA1 Message Date
shahondin1624 a0ad92dd47 Merge pull request 'refactor(MemberService): extract generic syncSubEntities() method' (#204) from fix/duplicate-sync-methods into main 2026-04-30 08:14:04 +02:00
shahondin1624 19adf0ba87 chore: move issue-204 plan to done 2026-04-30 08:13:11 +02:00
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
shahondin1624 c4f5f8e7fb Merge pull request 'fix(MemberService): wrap multi-step writes in database transactions' (#203) from fix/missing-database-transactions into main
Database Portability Tests / Unit Tests (PlatformHelper) (push) Failing after 38s
Database Portability Tests / Integration (mysql) (push) Has been skipped
Database Portability Tests / Integration (postgres) (push) Has been skipped
Database Portability Tests / Integration (sqlite) (push) Has been skipped
Database Portability Tests / Verify no MySQL-specific SQL (push) Successful in 6s
2026-04-29 22:11:02 +02:00
shahondin1624 0ffc993d3f chore: move issue-203 plan to done
Database Portability Tests / Unit Tests (PlatformHelper) (pull_request) Failing after 37s
Database Portability Tests / Integration (mysql) (pull_request) Has been skipped
Database Portability Tests / Integration (postgres) (pull_request) Has been skipped
Database Portability Tests / Integration (sqlite) (pull_request) Has been skipped
Database Portability Tests / Verify no MySQL-specific SQL (pull_request) Successful in 5s
2026-04-29 22:09:20 +02:00
shahondin1624 e1d24d4d54 fix(MemberService): wrap multi-step writes in database transactions
Add IDBConnection dependency to MemberService and wrap create(),
update(), and softDelete() in transactions (beginTransaction/commit/
rollback). This ensures atomicity when inserting/updating members
alongside sub-entities (addresses, phones, emails) — a failure at
any step now rolls back the entire operation instead of leaving
orphaned records.

(Closes #203)
2026-04-29 22:08:38 +02:00
shahondin1624 5c67fc763a Merge pull request 'Fix findArchived() filtering all members in memory (Closes #202)' (#202) from fix/find-archived-filtering-in-memory into main
Database Portability Tests / Unit Tests (PlatformHelper) (push) Failing after 37s
Database Portability Tests / Integration (mysql) (push) Has been skipped
Database Portability Tests / Integration (postgres) (push) Has been skipped
Database Portability Tests / Integration (sqlite) (push) Has been skipped
Database Portability Tests / Verify no MySQL-specific SQL (push) Successful in 5s
2026-04-29 21:14:55 +02:00
shahondin1624 ccda3ee570 Fix findArchived() filtering all members in memory (Closes #202)
Database Portability Tests / Unit Tests (PlatformHelper) (pull_request) Failing after 42s
Database Portability Tests / Integration (mysql) (pull_request) Has been skipped
Database Portability Tests / Integration (postgres) (pull_request) Has been skipped
Database Portability Tests / Integration (sqlite) (pull_request) Has been skipped
Database Portability Tests / Verify no MySQL-specific SQL (pull_request) Successful in 5s
Replace in-memory array_filter with a dedicated SQL query
WHERE deleted_at IS NOT NULL in MemberMapper::findArchived().

This fixes broken pagination (limit/offset now apply to archived
members only), eliminates unnecessary data transfer, and follows
the same pattern as the countArchived() fix from #201.
2026-04-29 21:13:32 +02:00
shahondin1624 d28dcd4541 Merge pull request 'Fix countArchived() loading all members into memory (Closes #201)' (#201) from fix/n-plus-one-queries-member-list into main
Database Portability Tests / Unit Tests (PlatformHelper) (push) Failing after 38s
Database Portability Tests / Integration (mysql) (push) Has been skipped
Database Portability Tests / Integration (postgres) (push) Has been skipped
Database Portability Tests / Integration (sqlite) (push) Has been skipped
Database Portability Tests / Verify no MySQL-specific SQL (push) Successful in 5s
2026-04-29 19:17:36 +02:00
shahondin1624 d4e25fe739 Fix countArchived() loading all members into memory (Closes #201)
Database Portability Tests / Unit Tests (PlatformHelper) (pull_request) Failing after 38s
Database Portability Tests / Integration (mysql) (pull_request) Has been skipped
Database Portability Tests / Integration (postgres) (pull_request) Has been skipped
Database Portability Tests / Integration (sqlite) (pull_request) Has been skipped
Database Portability Tests / Verify no MySQL-specific SQL (pull_request) Successful in 5s
Replace in-memory filtering with a SQL COUNT(*) query in the mapper,
matching the pattern of the existing countAll() method.
2026-04-29 19:13:52 +02:00
shahondin1624 967bacf231 docs: add tea CLI workflow to CLAUDE.md for Gitea operations 2026-04-28 21:56:46 +02:00
shahondin1624 ee569250ad Fix N+1 query problem in MemberService (Closes #200)
Database Portability Tests / Unit Tests (PlatformHelper) (push) Failing after 40s
Database Portability Tests / Integration (mysql) (push) Has been skipped
Database Portability Tests / Integration (postgres) (push) Has been skipped
Database Portability Tests / Integration (sqlite) (push) Has been skipped
Database Portability Tests / Verify no MySQL-specific SQL (push) Successful in 5s
- MemberMapper: 8 new *WithRelations() methods that fetch members with
  addresses, phones, and emails in a single query using LEFT JOINs
- MemberMapper: addJoinClauses() and fetchWithRelations() private helpers
  that handle JOIN duplication (one member × multiple sub-entities)
- MemberService: refactored findAll, findByFamily, findByStatus, search,
  findByBirthdayThisMonth, findWithUnpaidFees, findFiltered, fullTextSearch
  to delegate to joined mapper methods
- MemberService: added arrayToMember() and arrayToAddress() helpers so
  buildMatchContext() works with flat-array results from fullTextSearch
- MemberServiceTest: updated all existing tests to mock new method names
  and return flat-array format with nested sub-entities
- MemberServiceTest: added 10 new tests covering joined methods, backward
  compatibility, and correct shape of returned data
- Moved issue-200 plan from open/ to done/
2026-04-28 21:35:42 +02:00
shahondin1624 b29a268b1d Restructure .plans/ into done/ and open/ subdirectories
- Move completed plan files to .plans/done/
- Move 18 open plan files to .plans/open/
- Update .gitignore to exclude .verified_plans temp file
- Verified all 18 open plans still describe unimplemented issues
2026-04-28 20:30:55 +02:00
45 changed files with 4268 additions and 260 deletions
+2 -3
View File
@@ -37,6 +37,5 @@ test-results/
# Release artifacts
artifacts/
# agent
plan.md
review.md
# Plan verification tracking
.plans/.verified_plans
@@ -0,0 +1,57 @@
# Issue #200: N+1 Query Problem in Member List
## Problem
The `MemberService::findAll()` method (and similar methods) loads members, then makes 3 additional queries per member to fetch addresses, phones, and emails:
```php
public function findAll(?int $limit = null, ?int $offset = null): array {
$members = $this->memberMapper->findAll($limit, $offset);
return array_map(function (Member $member) {
$id = $member->getId();
$addresses = $this->addressMapper->findByMemberId($id); // +1 query
$phones = $this->phoneMapper->findByMemberId($id); // +1 query
$emails = $this->emailMapper->findByMemberId($id); // +1 query
return $this->buildMemberResponse($member, $addresses, $phones, $emails);
}, $members);
}
```
For a page of 20 members, this results in **1 + 20×3 = 61 queries**.
## Impact
- Slow page loads, especially with pagination
- Increased database load
- Poor performance on larger member lists
## Solution
Create a new mapper method that uses JOINs to fetch all data in a single query:
```sql
SELECT m.*, a.*, p.*, e.*
FROM mv_members m
LEFT JOIN mv_addresses a ON m.id = a.member_id
LEFT JOIN mv_phones p ON m.id = p.member_id
LEFT JOIN mv_emails e ON m.id = e.member_id
WHERE m.deleted_at IS NULL
ORDER BY m.nachname, m.vorname
```
Then parse the result set in PHP to group related sub-entities.
## Tasks
- [ ] Create `MemberMapper::findAllWithRelations(?int $limit, ?int $offset): array` that returns raw joined data
- [ ] Create a DTO or use an associative array to represent joined results
- [ ] Update `MemberService::findAll()` to use the new method
- [ ] Apply same pattern to `findFiltered()`, `findByFamily()`, `search()`, `fullTextSearch()`
- [ ] Benchmark before/after query counts
## Labels
- enhancement
- backend
- priority:high
- performance
@@ -0,0 +1,51 @@
# Issue #201: countArchived() Loads All Members into Memory
## Problem
The `MemberService::countArchived()` method loads ALL members into memory just to count archived ones:
```php
public function countArchived(): int {
$allMembers = $this->memberMapper->findAll(null, null, true);
return count(array_filter($allMembers, fn(Member $m) => $m->getDeletedAt() !== null));
}
```
This is extremely inefficient for large member databases.
## Impact
- Memory exhaustion with large member counts
- Slow archive page loads
- N+1 query problem (sub-entities loaded unnecessarily)
## Solution
Add a COUNT query to the mapper:
```php
public function countArchived(): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->createFunction('COUNT(*)'))
->from($this->getTableName())
->where($qb->expr()->isNull('deleted_at'));
// Add: where status = 'geloescht' or deleted_at IS NOT NULL
...
}
```
Alternatively, add a `deleted_at IS NOT NULL` check to the existing `findAll()` method and return only the count.
## Tasks
- [ ] Add `MemberMapper::countArchived(): int` using `SELECT COUNT(*) WHERE deleted_at IS NOT NULL`
- [ ] Update `MemberService::countArchived()` to use the new mapper method
- [ ] Verify the count matches the archive list page size
## Labels
- bug
- backend
- priority:high
- performance
@@ -0,0 +1,52 @@
# Issue #202: findArchived() Filters in PHP Instead of SQL
## Problem
The `MemberService::findArchived()` method fetches ALL members then filters in PHP:
```php
public function findArchived(?int $limit = null, ?int $offset = null): array {
$members = $this->memberMapper->findAll($limit, $offset, true);
$archived = array_filter($members, fn(Member $m) => $m->getDeletedAt() !== null);
return array_values(array_map(function (Member $member) {
// ...
}, $archived));
}
```
This means pagination is broken — `limit` and `offset` apply to ALL members, not just archived ones.
## Impact
- Incorrect pagination on archive page
- Unnecessary data transfer (all members, not just archived)
- N+1 query problem (sub-entities loaded unnecessarily)
## Solution
Add a dedicated mapper method:
```php
public function findArchived(?int $limit = null, ?int $offset = null): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->isNull('deleted_at'))
->andWhere($qb->expr()->isNotNull('deleted_at')) // or status check
->orderBy('deleted_at', 'DESC')
// ... pagination
}
```
## Tasks
- [ ] Add `MemberMapper::findArchived(?int $limit, ?int $offset): array`
- [ ] Update `MemberService::findArchived()` to use the new mapper method
- [ ] Remove the PHP array_filter call
- [ ] Test pagination with archived members
## Labels
- bug
- backend
- priority:high
@@ -0,0 +1,63 @@
# Issue #203: Missing Database Transactions in MemberService
## Problem
The `MemberService::create()` method inserts a member and its sub-entities (addresses, phones, emails) in separate queries without a transaction:
```php
public function create(array $data, array $addresses = [], ...): array {
// ...
$member = $this->memberMapper->insert($member);
$savedAddresses = $this->saveAddresses($member->getId(), $addresses);
$savedPhones = $this->savePhones($member->getId(), $phones);
$savedEmails = $this->saveEmails($member->getId(), $emails);
// ...
}
```
If `savePhones()` fails, the member is created but phones are not attached.
Similarly, `softDelete()` deletes sub-entities separately without a transaction.
## Impact
- Data inconsistency if partial failures occur
- Orphaned sub-entities on failed member creation
- Orphaned member on failed sub-entity creation
## Solution
Wrap the operation in a transaction:
```php
use OCP\DB\Events\SQLFileEmitter;
public function create(array $data, ...): array {
$this->db->beginTransaction();
try {
$member = $this->memberMapper->insert($member);
$savedAddresses = $this->saveAddresses($member->getId(), $addresses);
// ...
$this->db->commit();
} catch (\Exception $e) {
$this->db->rollback();
throw $e;
}
}
```
## Tasks
- [ ] Add transaction wrapping to `MemberService::create()`
- [ ] Add transaction wrapping to `MemberService::softDelete()`
- [ ] Add transaction wrapping to `MemberService::update()` (sync operations)
- [ ] Verify rollback works correctly on failure
- [ ] Consider adding a `DbTransactionTrait` for reusability
## Labels
- bug
- backend
- priority:high
- data-integrity
@@ -0,0 +1,65 @@
# Issue #204: Duplicate Sub-Entity Sync Methods
## Problem
`MemberService` contains three nearly identical methods for syncing sub-entities:
- `syncAddresses()`
- `syncPhones()`
- `syncEmails()`
They all follow the same pattern:
1. Fetch existing entities
2. Index by ID
3. Update kept IDs, create new ones
4. Delete removed IDs
This violates DRY (Don't Repeat Yourself) and makes maintenance harder.
## Current Code
```php
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);
}
}
}
// syncPhones() and syncEmails() are identical except for Address → Phone/Email
```
## Solution
Extract the common sync logic into a single private generic method that accepts callables for the mapper-specific operations. Each of the three sync methods becomes a thin 10-line wrapper calling the generic method.
## Tasks
- [ ] Extract common sync logic into `syncSubEntities()` private generic method
- [ ] Refactor `syncAddresses()`, `syncPhones()`, `syncEmails()` to use the new pattern
- [ ] Ensure identical behavior is preserved (all existing tests must pass)
- [ ] Run `make test` — all must pass
- [ ] Commit, push, create PR via tea, merge it
- [ ] Move plan file from `.plans/open/` to `.plans/done/`
- [ ] Check out main again
## Labels
- refactoring
- backend
- priority:medium
@@ -0,0 +1,59 @@
# Issue #205: Date Calculations Duplicated in Frontend
## Problem
The `MemberList.vue` component contains duplicate date calculation logic for `alter` (age) and `mitgliedsdauer` (membership duration) that also exists in the backend `MemberService`:
**Backend** (`lib/Service/MemberService.php`):
```php
private function calculateMitgliedsdauer(Member $member): string {
$eintritt = new DateTime($member->getEintritt());
$end = $member->getAustritt()
? new DateTime($member->getAustritt())
: new DateTime();
$diff = $eintritt->diff($end);
// ...
}
```
**Frontend** (`src/views/MemberList.vue`):
```javascript
function calculateAge(geburtsdatum) {
const birth = new Date(geburtsdatum)
const today = new Date()
let age = today.getFullYear() - birth.getFullYear()
// ...
}
function calculateMitgliedsdauer(eintritt) {
const start = new Date(eintritt)
const today = new Date()
// ...
}
```
## Impact
- Inconsistent calculations if date logic changes
- Maintenance burden (two places to update)
- Potential for bugs if frontend and backend diverge
## Solution
1. **Option A (Recommended)**: Calculate `alter` and `mitgliedsdauer` in the backend and include them in the API response
2. **Option B**: Extract to a shared utility in a format both can use (e.g., precompute in migration, or use a build-time code generator)
## Tasks
- [ ] Add `alter` and `mitgliedsdauer` to `Member::jsonSerialize()`
- [ ] Update `MemberService::buildMemberResponse()` to compute these values
- [ ] Remove duplicate calculations from `MemberList.vue`
- [ ] Verify frontend displays match backend calculations
- [ ] Update any other views using these calculations
## Labels
- refactoring
- frontend
- backend
- priority:medium
@@ -0,0 +1,64 @@
# Issue #206: Missing Unit Tests for Core Services
## Problem
The codebase has PHP unit tests (`tests/` directory) but the core services lack comprehensive test coverage. Key untested areas:
- `MemberService::create()` - no tests for validation, duplicate detection
- `MemberService::update()` - no tests for field filtering, partial updates
- `MemberService::softDelete()` - no tests for data purge behavior
- `MemberService::findFiltered()` - no tests for filter combinations
- `FeeCalculationService` - mentioned in docs but tests may be incomplete
- `AuditService` - no tests for field masking, diff computation
## Impact
- Risk of regressions when modifying services
- Harder to refactor with confidence
- Potential security issues (e.g., audit log masking) undetected
## Solution
Add comprehensive unit tests using PHPUnit:
```php
class MemberServiceTest extends\TestCase {
/** @var MemberService */
private $service;
protected function setUp(): void {
// Mock mappers and services
}
public function testCreateValidatesRequiredFields(): void {
$this->expectException(ValidationException::class);
$this->service->create([]);
}
public function testCreateDetectsDuplicates(): void {
$this->expectException(DuplicateMemberException::class);
$this->service->create([...]);
}
// ... more tests
}
```
## Tasks
- [ ] Set up PHPUnit with mocks for mappers and services
- [ ] Test `MemberService::create()` - required fields, dates, duplicates
- [ ] Test `MemberService::update()` - editable fields filter, partial updates
- [ ] Test `MemberService::softDelete()` - data purge verification
- [ ] Test `MemberService::findFiltered()` - all filter combinations
- [ ] Test `AuditService::maskIfEncrypted()` - field masking
- [ ] Test `FeeCalculationService::calculateAnnualFee()`
- [ ] Achieve >70% code coverage
## Labels
- enhancement
- testing
- backend
- priority:high
@@ -0,0 +1,74 @@
# Issue #207: Authorization Middleware Uses instanceof Chains
## Problem
The `AuthorizationMiddleware::getAdminMethods()` method uses a chain of `instanceof` checks to determine admin-only methods:
```php
private function getAdminMethods($controller): ?array {
if ($controller instanceof FeeController) {
return self::ADMIN_METHODS_FEE;
}
if ($controller instanceof StufeController) {
return self::ADMIN_METHODS_STUFE;
}
if ($controller instanceof ExportController) {
return self::ADMIN_METHODS_EXPORT;
}
if ($controller instanceof MemberController) {
return self::ADMIN_METHODS_MEMBER;
}
// ... more controllers
return null;
}
```
This pattern violates Open/Closed Principle — adding a new controller requires modifying the middleware.
## Impact
- Hard to maintain as controller count grows
- Easy to forget to add a new controller to the chain
- Mixing of concerns (controller identity vs. permissions)
## Solution
Use PHP attributes to declare permissions on controller methods:
```php
#[Attribute]
class RequiresAdmin implements \Attribute {}
#[RequiresAdmin]
public function createRule(): JSONResponse { ... }
```
Then scan for attributes in middleware:
```php
public function beforeController($controller, $methodName): void {
$method = new \ReflectionMethod($controller, $methodName);
if ($method->getAttributes(RequiresAdmin::class)) {
if (!$this->permissionService->isAdmin($userId)) {
throw new \RuntimeException('Authorization: admin required');
}
}
// ... rest of checks
}
```
## Tasks
- [ ] Create `RequiresRead` and `RequiresWrite` attributes
- [ ] Create `RequiresAdmin` attribute
- [ ] Add attributes to all controller methods
- [ ] Refactor `AuthorizationMiddleware` to use reflection + attributes
- [ ] Remove `ADMIN_METHODS_*` constants and `getAdminMethods()` method
- [ ] Test all permission levels still work correctly
## Labels
- refactoring
- backend
- priority:medium
- architecture
@@ -0,0 +1,69 @@
# Issue #208: MemberController Has Too Many Responsibilities
## Problem
`MemberController` handles:
- Member CRUD (index, show, create, update, destroy)
- Sub-entity CRUD (addresses, phones, emails)
- Admin-only operations (revealAllergies, archive)
- Search (search, fullTextSearch)
This violates Single Responsibility Principle. The controller is 500+ lines with 20+ methods.
## Current Structure
```
MemberController
├── search() # Full-text search
├── index() # List with filters
├── show() # Single member
├── create() # Create member
├── update() # Update member
├── destroy() # Delete member
├── revealAllergies() # Admin-only
├── archive() # Admin-only
├── createAddress() # Sub-entity
├── updateAddress() # Sub-entity
├── destroyAddress() # Sub-entity
├── createPhone() # Sub-entity
├── updatePhone() # Sub-entity
├── destroyPhone() # Sub-entity
├── createEmail() # Sub-entity
├── updateEmail() # Sub-entity
├── destroyEmail() # Sub-entity
```
## Solution
Split into focused controllers:
```
Controllers/
├── MemberController # Member CRUD only
├── MemberAddressController # Address CRUD
├── MemberPhoneController # Phone CRUD
├── MemberEmailController # Email CRUD
├── MemberSearchController # Search operations
├── MemberAdminController # Admin-only operations
```
Each controller has 3-5 methods, making them easier to understand and test.
## Tasks
- [ ] Create `MemberAddressController` for address CRUD
- [ ] Create `MemberPhoneController` for phone CRUD
- [ ] Create `MemberEmailController` for email CRUD
- [ ] Create `MemberSearchController` for search operations
- [ ] Create `MemberAdminController` for admin operations
- [ ] Move sub-entity and admin methods from MemberController
- [ ] Update routes in `AppInfo/Application.php`
- [ ] Update frontend API calls to use new endpoints
- [ ] Remove old methods from MemberController
## Labels
- refactoring
- backend
- priority:medium
- architecture
@@ -0,0 +1,60 @@
# Issue #209: No API Versioning Strategy
## Problem
All API endpoints use `/api/v1/` prefix but there's no formal versioning strategy. When breaking changes are needed (e.g., restructuring the member response), there's no clean migration path.
Current endpoints:
```
/apps/mitgliederverwaltung/api/v1/members
/apps/mitgliederverwaltung/api/v1/families
/apps/mitgliederverwaltung/api/v1/fees
...
```
## Impact
- Breaking frontend changes require immediate backend deployment
- No backward compatibility for mobile apps or third-party integrations
- Hard to deprecate old formats gracefully
## Solution
Implement a proper versioning strategy:
1. **URL Versioning** (current approach):
```
/api/v1/members → Current version
/api/v2/members → New version with breaking changes
```
2. **Header Versioning** (alternative):
```
GET /api/members
Accept: application/vnd.mitgliederverwaltung.v1+json
GET /api/members
Accept: application/vnd.mitgliederverwaltung.v2+json
```
3. **Deprecation Path**:
- v1 marked as deprecated in response headers
- v2 available in parallel
- v1 removed after 6 months
## Tasks
- [ ] Document API versioning strategy in docs/
- [ ] Implement v2 endpoints for critical entities (Member first)
- [ ] Add deprecation headers to v1 responses
- [ ] Update frontend to use v2 endpoints
- [ ] Create migration guide for v1 → v2
- [ ] Set deprecation timeline (e.g., remove v1 in next major release)
## Labels
- enhancement
- backend
- api
- priority:low
- architecture
@@ -0,0 +1,91 @@
# Issue #210: Inconsistent Error Response Format
## Problem
Different controllers return errors in different formats:
```php
// MemberController
return new JSONResponse(['error' => 'Mitglied nicht gefunden'], Http::STATUS_NOT_FOUND);
// Some controllers might return
return new JSONResponse(['message' => 'Not found'], Http::STATUS_NOT_FOUND);
// Validation errors sometimes include field-level details:
return new JSONResponse(['error' => 'Validation failed', 'fields' => [...]], Http::STATUS_BAD_REQUEST);
```
This makes frontend error handling difficult.
## Impact
- Inconsistent frontend error parsing
- Difficulty implementing global error handling
- Poor developer experience when debugging
## Solution
Create a standardized error response format:
```php
class ApiResponse {
public static function success(mixed $data, int $status = 200): JSONResponse { ... }
public static function error(
string $message,
int $status,
?array $details = null,
?string $errorCode = null
): JSONResponse {
return new JSONResponse([
'success' => false,
'error' => [
'code' => $errorCode ?? self::getDefaultCode($status),
'message' => $message,
'details' => $details,
]
], $status);
}
}
```
Standard response format:
```json
{
"success": true,
"data": { ... },
"meta": {
"total": 42,
"limit": 20,
"offset": 0
}
}
```
Error response format:
```json
{
"success": false,
"error": {
"code": "MEMBER_NOT_FOUND",
"message": "Mitglied nicht gefunden",
"details": { "memberId": 123 }
}
}
```
## Tasks
- [ ] Create `ApiResponse` utility class
- [ ] Define standard error codes (MEMBER_NOT_FOUND, VALIDATION_ERROR, etc.)
- [ ] Update all controllers to use `ApiResponse::error()`
- [ ] Update frontend stores to handle new error format
- [ ] Document the response format in docs/
## Labels
- enhancement
- backend
- frontend
- api
- priority:medium
@@ -0,0 +1,78 @@
# Issue #211: Authorization Middleware Maintenance Burden
## Problem
The `AuthorizationMiddleware` requires manual maintenance for each new controller and method:
1. When adding a new admin-only controller, it must be added to the `beforeController` chain:
```php
if ($controller instanceof ImportController
|| $controller instanceof PermissionController
|| $controller instanceof AuditController
|| $controller instanceof DsgvoController
|| $controller instanceof BackupController
// NEW: Must add here
) { ... }
```
2. When adding an admin-only method, it must be added to a constant:
```php
private const ADMIN_METHODS_MEMBER = ['revealAllergies', 'archive'];
// NEW: Must add here
```
3. When adding a read-only method, it must be added to:
```php
private const READ_METHODS = [
'index', 'show', 'search', 'preview', ...
// NEW: Must add here
];
```
## Impact
- Easy to forget to add a new controller/method to the right list
- Growing maintenance burden as the app expands
- Potential security issues if a write method is accidentally omitted
- Violates Open/Closed Principle
## Solution
Use PHP 8 attributes to declare permissions directly on controller methods:
```php
#[Attribute]
class RequirePermission {
public function __construct(string $level) { ... }
}
class MemberController {
#[RequirePermission('read')]
public function index(): JSONResponse { ... }
#[RequirePermission('write')]
public function create(): JSONResponse { ... }
#[RequirePermission('admin')]
public function revealAllergies(): JSONResponse { ... }
}
```
Middleware then reads attributes via reflection.
## Tasks
- [ ] Create `RequirePermission` attribute class
- [ ] Add `#[RequirePermission]` attributes to all controller methods
- [ ] Refactor `AuthorizationMiddleware::beforeController()` to use reflection
- [ ] Remove hardcoded controller and method lists
- [ ] Add tests to verify permission enforcement
- [ ] Document the new attribute-based approach
## Labels
- refactoring
- backend
- security
- priority:medium
- architecture
@@ -0,0 +1,75 @@
# Issue #212: Inconsistent Error Handling Across Controllers
## Problem
Different controllers handle errors differently:
**Logging inconsistency:**
```php
// Some controllers log errors:
$this->logger->error('Failed to create member', ['exception' => $e, ...]);
// Some controllers don't log at all
```
**Exception handling inconsistency:**
```php
// Some controllers catch multiple exception types:
} catch (DoesNotExistException $e) {
return new JSONResponse(['error' => 'Not found'], 404);
} catch (\Exception $e) {
return new JSONResponse(['error' => 'Server error'], 500);
}
// Some controllers use generic catches:
} catch (Exception $e) { ... }
```
**Error message language:**
```php
return new JSONResponse(['error' => 'Mitglied nicht gefunden'], 404); // German
return new JSONResponse(['error' => 'Member not found'], 404); // English
```
## Impact
- Inconsistent user experience
- Difficult to debug production issues
- Potential information leakage (stack traces in logs)
- Mixed language in API responses
## Solution
1. Create a base controller with standardized error handling:
```php
abstract class BaseApiController extends ApiController {
protected function handleNotFound(DoesNotExistException $e): JSONResponse {
$this->logger->warning('Resource not found', ['exception' => $e]);
return $this->error('Resource not found', 404);
}
protected function handleError(\Exception $e, string $action): JSONResponse {
$this->logger->error("$action failed", ['exception' => $e]);
return $this->error('An error occurred', 500);
}
}
```
2. Define consistent error messages in one place (enum or constants)
3. Ensure all controllers extend the base class
## Tasks
- [ ] Create `BaseApiController` with standardized error methods
- [ ] Define `ErrorMessages` enum with all error strings
- [ ] Update all controllers to extend `BaseApiController`
- [ ] Ensure consistent logging (all errors logged, exceptions include context)
- [ ] Standardize on German or bilingual error messages
- [ ] Add tests for error handling behavior
## Labels
- bug
- backend
- priority:medium
@@ -0,0 +1,66 @@
# Issue #213: Frontend State Management Issues
## Problem
Several issues in the Pinia stores and Vue components:
### 1. Store State Not Persisted
The `members.js` store state (filters, pagination) is lost on page refresh. Users lose their filter settings.
### 2. No Optimistic UI Updates
When creating/updating/deleting members, the UI waits for the server response before updating. For better UX, implement optimistic updates:
```javascript
async function deleteMember(id) {
// Optimistic: remove from local state immediately
this.members = this.members.filter(m => m.id !== id)
try {
await axios.delete(url)
} catch (err) {
// Revert on failure
await this.fetchMembers()
throw err
}
}
```
### 3. Stale Data After Navigation
After updating a member in `MemberDetail.vue`, navigating back to `MemberList.vue` may show stale data if the list wasn't refetched.
### 4. Error State Not Cleared
`store.clearError()` exists but isn't always called before retry operations, potentially showing old errors alongside new data.
## Impact
- Poor UX (page refresh loses context)
- Slower perceived performance (no optimistic updates)
- Confusing states when data is stale
- Confusing error messages
## Solution
1. Add `pinia-plugin-persistedstate` to persist store state
2. Implement optimistic updates in CRUD operations
3. Use `watch` to refetch data when navigating back
4. Ensure `clearError()` is called at the start of all fetch operations
## Tasks
- [ ] Add `pinia-plugin-persistedstate` dependency
- [ ] Configure persistence for filter and pagination state
- [ ] Implement optimistic updates in `deleteMember()`, `updateMember()`, `createMember()`
- [ ] Add navigation watchers to refetch stale data
- [ ] Verify `clearError()` is called before all fetch operations
- [ ] Test page refresh preserves filter state
## Labels
- enhancement
- frontend
- ux
- priority:medium
@@ -0,0 +1,55 @@
# Issue #214: Rate Limiting Not Fully Implemented
## Problem
The `RateLimitMiddleware` exists but may not be properly configured or may have gaps:
1. **Bulk operations not rate-limited**: The `revealAllergies()` endpoint iterates over ALL members and decrypts each — this should be rate-limited per admin session.
2. **No per-endpoint limits**: Search endpoints could be abused for enumeration attacks.
3. **No limit feedback**: When rate limited, users don't know when they can retry.
## Impact
- Potential DoS via expensive operations (bulk decryption)
- Enumeration attacks on search endpoints
- Poor UX with no retry-after information
## Solution
1. Add specific rate limits to expensive endpoints:
```php
// In middleware configuration
'member.allergien.reveal' => ['limit' => 1, 'period' => 3600], // 1 per hour
'member.search' => ['limit' => 30, 'period' => 60], // 30 per minute
```
2. Return proper rate limit headers:
```php
$response->headers->set('X-RateLimit-Remaining', (string)$remaining);
$response->headers->set('X-RateLimit-Reset', (string)$resetTime);
```
3. Return 429 with Retry-After header when limited:
```php
return new JSONResponse(['error' => 'Rate limit exceeded'], 429, [
'Retry-After' => $secondsRemaining
]);
```
## Tasks
- [ ] Audit all endpoints for rate limit requirements
- [ ] Configure per-endpoint rate limits
- [ ] Add X-RateLimit-* response headers
- [ ] Return 429 with Retry-After when limited
- [ ] Document rate limit configuration
- [ ] Add tests for rate limiting behavior
## Labels
- enhancement
- security
- backend
- priority:medium
@@ -0,0 +1,59 @@
# Issue #215: Column Rendering Logic Duplicated Across Views
## Problem
The `allColumns` definition in `MemberList.vue` contains rendering logic that's duplicated when the same fields appear in other views (e.g., `MemberDetail.vue`, export templates):
```javascript
const allColumns = [
{ key: 'name', label: 'Name', render: m => m.nachname + ', ' + m.vorname },
{ key: 'stufe', label: 'Stufe', render: m => getStufeNameById(m.stufeId) },
{ key: 'status', label: 'Status', render: m => statusMap[m.status] || m.status },
// ...
]
```
The `statusMap`, `rolleMap`, `geschlechtMap`, `kvTypMap` translations are duplicated in multiple components.
## Impact
- Inconsistent rendering if one copy is updated but not others
- Maintenance burden (3+ places to update for a field change)
- Difficulty adding new columns consistently across views
## Solution
Create a shared column definitions module:
```javascript
// src/utils/columnDefinitions.js
export const STATUS_MAP = { aktiv: 'Aktiv', inaktiv: 'Inaktiv', geloescht: 'Gelöscht' }
export const ROLLE_MAP = { mitglied: 'Mitglied', erziehungsberechtigter: 'Erziehungsberechtigter' }
// ...
export const MEMBER_LIST_COLUMNS = [
{ key: 'name', label: 'Name', render: m => `${m.nachname}, ${m.vorname}` },
{ key: 'status', label: 'Status', render: m => STATUS_MAP[m.status] || m.status },
// ...
]
```
Then import in components:
```javascript
import { MEMBER_LIST_COLUMNS, STATUS_MAP } from '../utils/columnDefinitions.js'
```
## Tasks
- [ ] Create `src/utils/columnDefinitions.js` with all maps and column definitions
- [ ] Update `MemberList.vue` to import from the shared module
- [ ] Update `MemberDetail.vue` to use shared maps
- [ ] Update export templates to use shared definitions
- [ ] Update any other views using these maps
- [ ] Add TypeScript types for column definitions (if using TS)
## Labels
- refactoring
- frontend
- priority:low
@@ -0,0 +1,74 @@
# Issue #216: Missing Integration Tests
## Problem
While unit tests exist for some services, there are no integration tests that verify the full request-response cycle:
- No tests for API endpoint behavior
- No tests for database operations end-to-end
- No tests for authentication/authorization flow
- No tests for concurrent access patterns
- No tests for data migration scripts
## Impact
- Unknown behavior of API contracts
- Risk of breaking changes going undetected
- Difficult to verify authorization works correctly
- Risk of migration failures in production
## Solution
Add integration tests using Nextcloud's testing infrastructure:
```php
class MemberApiIntegrationTest extends \PHPUnit\Framework\TestCase {
use Helper\EmptyContainerTrait;
private ?AppFrameworkApp $app = null;
private ?MemberController $controller = null;
protected function setUp(): void {
$this->app = $this->container->get('app');
$this->controller = $this->app->getContainer()
->get(MemberController::class);
}
public function testCreateMemberRequiresAuthentication(): void {
$this->expectException(UnauthorizedException::class);
$this->controller->create();
}
public function testCreateMemberValidatesRequiredFields(): void {
$this->givenAuthenticatedUser('admin');
$response = $this->controller->create([]);
$this->assertEquals(400, $response->getStatusCode());
}
public function testSoftDeletePurgesSensitiveData(): void {
$member = $this->givenMemberWithAllergies();
$this->controller->destroy($member->id);
$deleted = $this->memberMapper->findById($member->id);
$this->assertNull($deleted->getAllergienEncrypted());
}
}
```
## Tasks
- [ ] Set up Nextcloud integration test environment
- [ ] Add integration tests for MemberController CRUD
- [ ] Add integration tests for authorization (admin vs. user)
- [ ] Add integration tests for FamilyController
- [ ] Add integration tests for FeeController
- [ ] Add integration tests for migration scripts
- [ ] Set up CI pipeline for integration tests
## Labels
- enhancement
- testing
- backend
- priority:high
@@ -0,0 +1,82 @@
# Issue #217: No Request Validation Layer
## Problem
Input validation is scattered across controllers and services:
1. **Controller level**: Some controllers validate input exists:
```php
$data = $this->getRequestData();
// Assumes $data is always valid
```
2. **Service level**: `MemberService::validateRequiredFields()` checks for required fields:
```php
private function validateRequiredFields(array $data): void {
$required = ['vorname', 'nachname', 'eintritt'];
// ...
}
```
3. **No type validation**: Phone numbers are validated via `PhoneValidator::validateAndNormalize()`, but other fields have no type checking.
4. **No schema validation**: There's no declarative validation schema.
## Impact
- Inconsistent validation (some fields validated, others not)
- Potential security issues (SQL injection mitigated by ORM, but XSS, etc. not checked)
- Difficult to know what validation exists for each endpoint
- No auto-generated API documentation from validation schemas
## Solution
Implement a validation layer using Symfony Validator or similar:
```php
class CreateMemberRequest {
#[Assert\NotBlank]
#[Assert\Length(min: 1, max: 100)]
public string $vorname;
#[Assert\NotBlank]
#[Assert\Length(min: 1, max: 100)]
public string $nachname;
#[Assert\NotBlank]
#[Assert\Date]
public string $eintritt;
#[Assert\Date]
public ?string $geburtsdatum = null;
#[Assert\Choice(['maennlich', 'weiblich', 'divers'])]
public ?string $geschlecht = null;
}
public function create(): JSONResponse {
$data = $this->getRequestData();
$violations = $this->validator->validate($data, CreateMemberRequest::class);
if (count($violations) > 0) {
return $this->validationError($violations);
}
// ...
}
```
## Tasks
- [ ] Add Symfony Validator dependency
- [ ] Create validation DTOs for each endpoint
- [ ] Add Assert annotations for all fields
- [ ] Implement validation middleware or base controller method
- [ ] Return structured validation errors (field → message)
- [ ] Update services to remove inline validation (keep business logic only)
- [ ] Add tests for validation rules
## Labels
- enhancement
- security
- backend
- priority:medium
+36
View File
@@ -83,3 +83,39 @@ This is a common problem. Nextcloud aggressively caches JS assets. Escalation pa
- Repo: `shahondin1624/Mitgliederverwaltung` on `git.shahondin1624.de`
- Issues use labels: `enhancement`, `bug`, `backend`, `frontend`, `security`, `epic`, `priority:high/medium/low`
- Close issues via commit message: `(Closes #N)`
### Using `tea` CLI
Always use the `tea` CLI for Gitea operations (never the web UI or raw API calls):
```bash
# Pull requests
teaprclose<PR>
teaprclose<PR>
teapr create --head <branch> --title "<title>" --labels "<labels>" --description "<body>"
teapr list
teapr view <PR>
teapr diff <PR>
teapr merge <PR>
# Issues
tei list --state open
tei create --title "<title>" --labels "<labels>"
tei view <Issue>
tei close <Issue>
tei update <Issue> --labels "<new-labels>" --assignees "<user>"
# Labels
tel list
tel create <name> --color "<hex>" --description "<desc>"
# Milestones
tems list
tms create --title "<title>" --deadline "YYYY-MM-DD"
```
**Rules:**
- Before creating a PR: ensure the branch is pushed to origin first, and verify there are actual commits on the branch (never create a PR from a branch identical to main/base)
- Always use `tea pr diff` to verify the PR has the expected changes before merging
- Use `tea pr merge` to merge after review
- Close issues via commit message `(Closes #N)``tea` is only for PR/issue management on the Gitea server
+1788
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -21,7 +21,7 @@
]
},
"require-dev": {
"phpunit/phpunit": "^10.0",
"phpunit/phpunit": "^10.5",
"nextcloud/ocp": "^28",
"doctrine/dbal": "^3.0",
"sabre/vobject": "^4.5"
Executable
BIN
View File
Binary file not shown.
+436
View File
@@ -232,6 +232,47 @@ class MemberMapper extends QBMapper {
return $this->findEntities($qb);
}
/**
* Full-text search across members, addresses, and notes with all
* sub-entities in a single query.
*
* Searches: vorname, nachname, notizen, zusatz_notizen, and joined
* address fields. Returns members with nested addresses, phones, and
* emails.
*
* Part of Issue #33.
*
* @return array<int, array> Flat member arrays with nested sub-entities
* @throws Exception
*/
public function fullTextSearchWithRelations(string $query, int $limit = 20): array {
$qb = $this->db->getQueryBuilder();
$searchPattern = '%' . $this->db->escapeLikeParameter($query) . '%';
$qb->select('m.*')
->from($this->getTableName(), 'm');
$this->addJoinClauses($qb);
$qb->where($qb->expr()->isNull('m.deleted_at'))
->andWhere(
$qb->expr()->orX(
$qb->expr()->iLike('m.vorname', $qb->createNamedParameter($searchPattern)),
$qb->expr()->iLike('m.nachname', $qb->createNamedParameter($searchPattern)),
$qb->expr()->iLike('m.notizen', $qb->createNamedParameter($searchPattern)),
$qb->expr()->iLike('m.zusatz_notizen', $qb->createNamedParameter($searchPattern)),
$qb->expr()->iLike('a.strasse', $qb->createNamedParameter($searchPattern)),
$qb->expr()->iLike('a.plz', $qb->createNamedParameter($searchPattern)),
$qb->expr()->iLike('a.ort', $qb->createNamedParameter($searchPattern))
)
)
->orderBy('m.nachname', 'ASC')
->addOrderBy('m.vorname', 'ASC')
->setMaxResults($limit);
return $this->fetchWithRelations($qb);
}
/**
* Find a member by exact Vorname, Nachname, and Geburtsdatum.
* Used for duplicate detection during import.
@@ -330,4 +371,399 @@ class MemberMapper extends QBMapper {
return $count;
}
/**
* Count all soft-deleted (archived) members.
*
* Uses a single SQL COUNT query instead of loading all members
* into memory.
*
* Part of Issue #201.
*
* @throws Exception
*/
public function countArchived(): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->createFunction('COUNT(*)'))
->from($this->getTableName())
->where($qb->expr()->isNotNull('deleted_at'));
$result = $qb->executeQuery();
$count = (int)$result->fetchOne();
$result->closeCursor();
return $count;
}
/**
* Find all soft-deleted (archived) members with optional pagination.
*
* Uses a single SQL query with WHERE deleted_at IS NOT NULL instead
* of loading all members into memory and filtering in PHP.
*
* Part of Issue #202.
*
* @return Member[]
* @throws Exception
*/
public function findArchived(?int $limit = null, ?int $offset = null): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->isNotNull('deleted_at'))
->orderBy('deleted_at', 'DESC');
if ($limit !== null) {
$qb->setMaxResults($limit);
}
if ($offset !== null) {
$qb->setFirstResult($offset);
}
return $this->findEntities($qb);
}
// ── Joined fetch methods (N+1 avoidance) ────────────────────────
/**
* Find all members with their addresses, phones, and emails in a
* single query using LEFT JOINs.
*
* Returns an array of associative arrays, each representing one
* member with nested 'addresses', 'phones', and 'emails' arrays.
*
* This replaces the N+1 pattern (1 query for members + 3 queries
* per member for sub-entities) with a single query regardless of
* the number of members.
*
* @param int|null $limit Pagination limit
* @param int|null $offset Pagination offset
* @return array<int, array> Flat member arrays with nested sub-entities
* @throws Exception
*/
public function findAllWithRelations(?int $limit = null, ?int $offset = null): array {
return $this->fetchWithRelations(
$this->buildBaseQuery(false, $limit, $offset)
);
}
/**
* Find members by family ID with all sub-entities in a single query.
*
* @param int $familyId Family ID
* @return array<int, array> Flat member arrays with nested sub-entities
* @throws Exception
*/
public function findByFamilyWithRelations(int $familyId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName(), 'm');
$this->addJoinClauses($qb);
$qb->where($qb->expr()->eq('m.family_id', $qb->createNamedParameter($familyId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->isNull('m.deleted_at'))
->orderBy('m.nachname', 'ASC')
->addOrderBy('m.vorname', 'ASC');
return $this->fetchWithRelations($qb);
}
/**
* Find members by status with all sub-entities in a single query.
*
* @param string $status Status to filter by
* @return array<int, array> Flat member arrays with nested sub-entities
* @throws Exception
*/
public function findByStatusWithRelations(string $status): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName(), 'm');
$this->addJoinClauses($qb);
$qb->where($qb->expr()->eq('m.status', $qb->createNamedParameter($status)))
->orderBy('m.nachname', 'ASC')
->addOrderBy('m.vorname', 'ASC');
return $this->fetchWithRelations($qb);
}
/**
* Search members by name with all sub-entities in a single query.
*
* @param string $query Search query (searches Vorname and Nachname)
* @return array<int, array> Flat member arrays with nested sub-entities
* @throws Exception
*/
public function searchWithRelations(string $query): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName(), 'm');
$this->addJoinClauses($qb);
$searchPattern = '%' . $this->db->escapeLikeParameter($query) . '%';
$qb->where($qb->expr()->isNull('m.deleted_at'))
->andWhere(
$qb->expr()->orX(
$qb->expr()->iLike('m.vorname', $qb->createNamedParameter($searchPattern)),
$qb->expr()->iLike('m.nachname', $qb->createNamedParameter($searchPattern))
)
)
->orderBy('m.nachname', 'ASC')
->addOrderBy('m.vorname', 'ASC');
return $this->fetchWithRelations($qb);
}
/**
* Find members with a birthday in the given month with all
* sub-entities in a single query.
*
* @param int $month Month (1-12)
* @return array<int, array> Flat member arrays with nested sub-entities
* @throws Exception
*/
public function findByBirthdayMonthWithRelations(int $month): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName(), 'm');
$this->addJoinClauses($qb);
$monthExpr = PlatformHelper::getMonthExpression($this->db, 'm.geburtsdatum');
$qb->where($qb->expr()->isNull('m.deleted_at'))
->andWhere(
$qb->expr()->eq(
$qb->createFunction($monthExpr),
$qb->createNamedParameter($month, IQueryBuilder::PARAM_INT)
)
)
->orderBy('m.geburtsdatum', 'ASC');
return $this->fetchWithRelations($qb);
}
/**
* Find members who have unpaid fee records for a given year with
* all sub-entities in a single query.
*
* @param int $year Year to filter by
* @return array<int, array> Flat member arrays with nested sub-entities
* @throws Exception
*/
public function findWithUnpaidFeesWithRelations(int $year): array {
$qb = $this->db->getQueryBuilder();
$qb->select('m.*')
->from($this->getTableName(), 'm')
->innerJoin('m', 'mv_fee_records', 'f', $qb->expr()->eq('m.id', 'f.member_id'));
$this->addJoinClauses($qb);
$qb->where($qb->expr()->isNull('m.deleted_at'))
->andWhere($qb->expr()->eq('f.year', $qb->createNamedParameter($year, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('f.paid', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
->orderBy('m.nachname', 'ASC')
->addOrderBy('m.vorname', 'ASC');
return $this->fetchWithRelations($qb);
}
/**
* Find members matching a combination of filters with all
* sub-entities in a single query.
*
* @param string|null $status Filter by status (aktiv/inaktiv)
* @param string|null $rolle Filter by rolle (mitglied/erziehungsberechtigter)
* @param bool $birthdayThisMonth Only members with birthday in current month
* @param bool $unpaidFees Only members with unpaid fee records for current year
* @return array<int, array> Flat member arrays with nested sub-entities
* @throws Exception
*/
public function findFilteredWithRelations(
?string $status = null,
?string $rolle = null,
bool $birthdayThisMonth = false,
bool $unpaidFees = false
): array {
$qb = $this->db->getQueryBuilder();
$qb->select('m.*')
->from($this->getTableName(), 'm');
$this->addJoinClauses($qb);
$qb->where($qb->expr()->isNull('m.deleted_at'));
if ($unpaidFees) {
$year = (int)(new \DateTime())->format('Y');
$qb->innerJoin('m', 'mv_fee_records', 'f', $qb->expr()->eq('m.id', 'f.member_id'))
->andWhere($qb->expr()->eq('f.year', $qb->createNamedParameter($year, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('f.paid', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)));
}
if ($status !== null) {
$qb->andWhere($qb->expr()->eq('m.status', $qb->createNamedParameter($status)));
}
if ($rolle !== null) {
$qb->andWhere($qb->expr()->eq('m.rolle', $qb->createNamedParameter($rolle)));
}
if ($birthdayThisMonth) {
$currentMonth = (int)(new \DateTime())->format('m');
$monthExpr = PlatformHelper::getMonthExpression($this->db, 'm.geburtsdatum');
$qb->andWhere(
$qb->expr()->eq(
$qb->createFunction($monthExpr),
$qb->createNamedParameter($currentMonth, IQueryBuilder::PARAM_INT)
)
);
}
$qb->orderBy('m.nachname', 'ASC')
->addOrderBy('m.vorname', 'ASC');
return $this->fetchWithRelations($qb);
}
/**
* Build the base query for findAllWithRelations.
*/
private function buildBaseQuery(
bool $includeDeleted,
?int $limit,
?int $offset
): \OCP\DB\QueryBuilder\IQueryBuilder {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName(), 'm');
$this->addJoinClauses($qb);
if (!$includeDeleted) {
$qb->where($qb->expr()->isNull('m.deleted_at'));
}
$qb->orderBy('m.nachname', 'ASC')
->addOrderBy('m.vorname', 'ASC');
if ($limit !== null) {
$qb->setMaxResults($limit);
}
if ($offset !== null) {
$qb->setFirstResult($offset);
}
return $qb;
}
/**
* Add the LEFT JOIN clauses for addresses, phones, and emails
* to the given query builder.
*/
private function addJoinClauses(\OCP\DB\QueryBuilder\IQueryBuilder $qb): void {
$qb->leftJoin('m', 'mv_addresses', 'a', $qb->expr()->eq('m.id', 'a.member_id'))
->leftJoin('m', 'mv_phones', 'p', $qb->expr()->eq('m.id', 'p.member_id'))
->leftJoin('m', 'mv_emails', 'e', $qb->expr()->eq('m.id', 'e.member_id'));
}
/**
* Execute a query with joined sub-entities and parse the flat
* result set into nested member arrays.
*
* The query must SELECT all columns from the members table plus
* columns from mv_addresses, mv_phones, and mv_emails. Column
* name collision is handled by prefixing joined columns with
* aliases (a.*, p.*, e.*) which Doctrine maps as
* a_strasse, p_number_e164, etc.
*
* @throws Exception
*/
private function fetchWithRelations(\OCP\DB\QueryBuilder\IQueryBuilder $qb): array {
$result = $qb->executeQuery();
/** @var array<int, array> $grouped */
$grouped = [];
while ($row = $result->fetch()) {
$id = (int)$row['id'];
if (!isset($grouped[$id])) {
// Build the base member record from the member columns.
$grouped[$id] = [
'id' => $id,
'vorname' => $row['vorname'] ?? '',
'nachname' => $row['nachname'] ?? '',
'geburtsdatum' => $row['geburtsdatum'] ?? null,
'geschlecht' => $row['geschlecht'] ?? null,
'rolle' => $row['rolle'] ?? 'mitglied',
'stufeId' => isset($row['stufe_id']) ? (int)$row['stufe_id'] : null,
'eintritt' => $row['eintritt'] ?? '',
'austritt' => $row['austritt'] ?? null,
'status' => $row['status'] ?? 'aktiv',
'allergienEncrypted' => $row['allergien_encrypted'] ?? null,
'notizen' => $row['notizen'] ?? null,
'zusatzNotizen' => $row['zusatz_notizen'] ?? null,
'kvTyp' => $row['kv_typ'] ?? null,
'kvName' => $row['kv_name'] ?? null,
'familyId' => isset($row['family_id']) ? (int)$row['family_id'] : null,
'frozenFeeRate' => $row['frozen_fee_rate'] ?? null,
'calendarEventUri' => $row['calendar_event_uri'] ?? null,
'contactVcardUri' => $row['contact_vcard_uri'] ?? null,
'createdAt' => $row['created_at'] ?? '',
'updatedAt' => $row['updated_at'] ?? '',
'deletedAt' => $row['deleted_at'] ?? null,
'einwilligungDatum' => $row['einwilligung_datum'] ?? null,
'juleicaNummer' => $row['juleica_nummer'] ?? null,
'juleicaAblaufdatum' => $row['juleica_ablaufdatum'] ?? null,
'addresses' => [],
'phones' => [],
'emails' => [],
];
}
// Parse sub-entities from the joined columns.
// A row with NULLs for all address columns means no address
// is joined for this row — we must not create an empty Address.
$addrId = $row['a_id'] ?? null;
if ($addrId !== null && $addrId !== '') {
$grouped[$id]['addresses'][] = [
'id' => (int)$addrId,
'memberId' => (int)$row['a_member_id'],
'label' => $row['a_label'] ?? null,
'strasse' => $row['a_strasse'] ?? '',
'plz' => $row['a_plz'] ?? '',
'ort' => $row['a_ort'] ?? '',
'land' => $row['a_land'] ?? 'Deutschland',
'isPrimary' => (bool)$row['a_is_primary'],
];
}
$phoneId = $row['p_id'] ?? null;
if ($phoneId !== null && $phoneId !== '') {
$grouped[$id]['phones'][] = [
'id' => (int)$phoneId,
'memberId' => (int)$row['p_member_id'],
'label' => $row['p_label'] ?? null,
'numberE164' => $row['p_number_e164'] ?? '',
];
}
$emailId = $row['e_id'] ?? null;
if ($emailId !== null && $emailId !== '') {
$grouped[$id]['emails'][] = [
'id' => (int)$emailId,
'memberId' => (int)$row['e_member_id'],
'label' => $row['e_label'] ?? null,
'email' => $row['e_email'] ?? '',
];
}
}
$result->closeCursor();
// Re-index to a sequential 0-based array while preserving order.
return array_values($grouped);
}
}
+192 -147
View File
@@ -19,6 +19,7 @@ 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;
/**
@@ -37,6 +38,7 @@ class MemberService {
private AuditService $auditService;
private LoggerInterface $logger;
private ?EncryptionService $encryptionService;
private IDBConnection $db;
public function __construct(
MemberMapper $memberMapper,
@@ -47,7 +49,8 @@ class MemberService {
StufeHistoryMapper $stufeHistoryMapper,
AuditService $auditService,
LoggerInterface $logger,
?EncryptionService $encryptionService = null
?EncryptionService $encryptionService,
IDBConnection $db
) {
$this->memberMapper = $memberMapper;
$this->addressMapper = $addressMapper;
@@ -58,6 +61,7 @@ class MemberService {
$this->auditService = $auditService;
$this->logger = $logger;
$this->encryptionService = $encryptionService;
$this->db = $db;
}
/**
@@ -153,6 +157,8 @@ class MemberService {
$member->setCreatedAt($now);
$member->setUpdatedAt($now);
$this->db->beginTransaction();
try {
/** @var Member $member */
$member = $this->memberMapper->insert($member);
@@ -160,6 +166,12 @@ class MemberService {
$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);
@@ -186,77 +198,56 @@ class MemberService {
/**
* 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 {
$members = $this->memberMapper->findAll($limit, $offset);
return array_map(function (Member $member) {
$id = $member->getId();
$addresses = $this->addressMapper->findByMemberId($id);
$phones = $this->phoneMapper->findByMemberId($id);
$emails = $this->emailMapper->findByMemberId($id);
return $this->buildMemberResponse($member, $addresses, $phones, $emails);
}, $members);
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 {
$members = $this->memberMapper->findByFamily($familyId);
return array_map(function (Member $member) {
return $this->buildMemberResponse(
$member,
$this->addressMapper->findByMemberId($member->getId()),
$this->phoneMapper->findByMemberId($member->getId()),
$this->emailMapper->findByMemberId($member->getId())
);
}, $members);
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 {
$members = $this->memberMapper->findByStatus($status);
return array_map(function (Member $member) {
return $this->buildMemberResponse(
$member,
$this->addressMapper->findByMemberId($member->getId()),
$this->phoneMapper->findByMemberId($member->getId()),
$this->emailMapper->findByMemberId($member->getId())
);
}, $members);
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 {
$members = $this->memberMapper->search($query);
return array_map(function (Member $member) {
return $this->buildMemberResponse(
$member,
$this->addressMapper->findByMemberId($member->getId()),
$this->phoneMapper->findByMemberId($member->getId()),
$this->emailMapper->findByMemberId($member->getId())
);
}, $members);
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[]
@@ -264,20 +255,14 @@ class MemberService {
*/
public function findByBirthdayThisMonth(): array {
$currentMonth = (int)(new DateTime())->format('m');
$members = $this->memberMapper->findByBirthdayMonth($currentMonth);
return array_map(function (Member $member) {
return $this->buildMemberResponse(
$member,
$this->addressMapper->findByMemberId($member->getId()),
$this->phoneMapper->findByMemberId($member->getId()),
$this->emailMapper->findByMemberId($member->getId())
);
}, $members);
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[]
@@ -285,20 +270,14 @@ class MemberService {
*/
public function findWithUnpaidFees(): array {
$currentYear = (int)(new DateTime())->format('Y');
$members = $this->memberMapper->findWithUnpaidFees($currentYear);
return array_map(function (Member $member) {
return $this->buildMemberResponse(
$member,
$this->addressMapper->findByMemberId($member->getId()),
$this->phoneMapper->findByMemberId($member->getId()),
$this->emailMapper->findByMemberId($member->getId())
);
}, $members);
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
*/
@@ -308,43 +287,88 @@ class MemberService {
bool $birthdayThisMonth = false,
bool $unpaidFees = false
): array {
$members = $this->memberMapper->findFiltered($status, $rolle, $birthdayThisMonth, $unpaidFees);
return array_map(function (Member $member) {
return $this->buildMemberResponse(
$member,
$this->addressMapper->findByMemberId($member->getId()),
$this->phoneMapper->findByMemberId($member->getId()),
$this->emailMapper->findByMemberId($member->getId())
);
}, $members);
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 {
$members = $this->memberMapper->fullTextSearch($query, $limit);
$rawResults = $this->memberMapper->fullTextSearchWithRelations($query, $limit);
$lowQuery = mb_strtolower($query);
return array_map(function (Member $member) use ($lowQuery) {
$id = $member->getId();
$addresses = $this->addressMapper->findByMemberId($id);
$phones = $this->phoneMapper->findByMemberId($id);
$emails = $this->emailMapper->findByMemberId($id);
$result = $this->buildMemberResponse($member, $addresses, $phones, $emails);
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
$result['matchContext'] = $this->buildMatchContext($member, $addresses, $lowQuery);
$row['matchContext'] = $this->buildMatchContext(
$this->arrayToMember($row),
$addresses,
$lowQuery
);
return $result;
}, $members);
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;
}
/**
@@ -503,6 +527,8 @@ class MemberService {
$member->setUpdatedAt((new DateTime())->format('Y-m-d H:i:s'));
$this->db->beginTransaction();
try {
/** @var Member $member */
$member = $this->memberMapper->update($member);
@@ -518,11 +544,62 @@ class MemberService {
$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:
@@ -534,28 +611,15 @@ class MemberService {
* @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);
}
}
$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)
);
}
/**
@@ -565,28 +629,15 @@ class MemberService {
* @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);
}
}
$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)
);
}
/**
@@ -596,28 +647,15 @@ class MemberService {
* @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);
}
}
$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) ────────────────────────────────────────────────
@@ -673,6 +711,8 @@ class MemberService {
$member->setCalendarEventUri(null);
$member->setContactVcardUri(null);
$this->db->beginTransaction();
try {
$this->memberMapper->update($member);
// Hard-delete sub-entities (addresses, phones, emails)
@@ -680,6 +720,12 @@ class MemberService {
$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);
@@ -726,10 +772,7 @@ class MemberService {
* @throws Exception
*/
public function findArchived(?int $limit = null, ?int $offset = null): array {
$members = $this->memberMapper->findAll($limit, $offset, true);
// Filter to only soft-deleted members
$archived = array_filter($members, fn(Member $m) => $m->getDeletedAt() !== null);
$members = $this->memberMapper->findArchived($limit, $offset);
return array_values(array_map(function (Member $member) {
// Only return retained fields
@@ -743,17 +786,19 @@ class MemberService {
'deletedAt' => $member->getDeletedAt(),
'mitgliedsdauer' => $this->calculateMitgliedsdauer($member),
];
}, $archived));
}, $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 {
$allMembers = $this->memberMapper->findAll(null, null, true);
return count(array_filter($allMembers, fn(Member $m) => $m->getDeletedAt() !== null));
return $this->memberMapper->countArchived();
}
/**
+39
View File
@@ -543,6 +543,45 @@ class MapperTest extends TestCase {
$this->assertSame(0, $count);
}
public function testMemberMapperCountArchived(): void {
$this->configureResultCount(7);
$mapper = new MemberMapper($this->db);
$count = $mapper->countArchived();
$this->assertSame(7, $count);
}
public function testMemberMapperCountArchivedZero(): void {
$this->configureResultCount(0);
$mapper = new MemberMapper($this->db);
$count = $mapper->countArchived();
$this->assertSame(0, $count);
}
public function testMemberMapperFindArchivedNoParams(): void {
$row = $this->memberRow(1);
$row['deleted_at'] = '2026-01-01 00:00:00';
$this->configureResultRows([$row]);
$mapper = new MemberMapper($this->db);
$members = $mapper->findArchived();
$this->assertCount(1, $members);
}
public function testMemberMapperFindArchivedWithPagination(): void {
$row = $this->memberRow(1);
$row['deleted_at'] = '2026-01-01 00:00:00';
$this->configureResultRows([$row]);
$mapper = new MemberMapper($this->db);
$members = $mapper->findArchived(10, 0);
$this->assertCount(1, $members);
}
public function testMemberMapperFindArchivedEmpty(): void {
$this->configureResultRows([]);
$mapper = new MemberMapper($this->db);
$members = $mapper->findArchived();
$this->assertCount(0, $members);
}
// ══════════════════════════════════════════════════════════════════
// ── FamilyMapper ─────────────────────────────────────────────────
// ══════════════════════════════════════════════════════════════════
+513 -81
View File
@@ -20,6 +20,7 @@ use OCA\Mitgliederverwaltung\Service\EncryptionService;
use OCA\Mitgliederverwaltung\Service\MemberService;
use OCA\Mitgliederverwaltung\Service\ValidationException;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IDBConnection;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
@@ -35,6 +36,7 @@ class MemberServiceTest extends TestCase {
private StufeHistoryMapper&MockObject $stufeHistoryMapper;
private AuditService&MockObject $auditService;
private LoggerInterface&MockObject $logger;
private IDBConnection&MockObject $db;
protected function setUp(): void {
parent::setUp();
@@ -59,6 +61,7 @@ class MemberServiceTest extends TestCase {
$this->stufeHistoryMapper = $this->createMock(StufeHistoryMapper::class);
$this->auditService = $this->createMock(AuditService::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->db = $this->createMock(IDBConnection::class);
$this->service = new MemberService(
$this->memberMapper,
@@ -68,7 +71,9 @@ class MemberServiceTest extends TestCase {
$this->familyMapper,
$this->stufeHistoryMapper,
$this->auditService,
$this->logger
$this->logger,
null,
$this->db
);
}
@@ -186,10 +191,10 @@ class MemberServiceTest extends TestCase {
$member1 = $this->createMember(1, 'Max');
$member2 = $this->createMember(2, 'Anna', 'Schmidt');
$this->memberMapper->method('findAll')->willReturn([$member1, $member2]);
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
$this->memberMapper->method('findAllWithRelations')->willReturn([
$member1->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
$member2->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
]);
$result = $this->service->findAll();
@@ -200,7 +205,7 @@ class MemberServiceTest extends TestCase {
public function testFindAllWithPagination(): void {
$this->memberMapper->expects($this->once())
->method('findAll')
->method('findAllWithRelations')
->with(10, 5)
->willReturn([]);
@@ -208,7 +213,7 @@ class MemberServiceTest extends TestCase {
}
public function testFindAllReturnsEmptyArray(): void {
$this->memberMapper->method('findAll')->willReturn([]);
$this->memberMapper->method('findAllWithRelations')->willReturn([]);
$result = $this->service->findAll();
$this->assertSame([], $result);
@@ -225,9 +230,6 @@ class MemberServiceTest extends TestCase {
$m->setId(1);
return $m;
});
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
$this->auditService->expects($this->once())->method('logCreate');
@@ -709,7 +711,8 @@ class MemberServiceTest extends TestCase {
$this->stufeHistoryMapper,
$this->auditService,
$this->logger,
$encryption
$encryption,
$this->db
);
$map = $service->revealAllAllergies('alice');
@@ -747,7 +750,8 @@ class MemberServiceTest extends TestCase {
$this->stufeHistoryMapper,
$this->auditService,
$this->logger,
$encryption
$encryption,
$this->db
);
$service->revealAllAllergies('alice');
@@ -900,10 +904,9 @@ class MemberServiceTest extends TestCase {
public function testSearchReturnsMatchingMembers(): void {
$member = $this->createMember(1);
$this->memberMapper->method('search')->with('Max')->willReturn([$member]);
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
$this->memberMapper->method('searchWithRelations')->with('Max')->willReturn([
$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
]);
$result = $this->service->search('Max');
@@ -912,7 +915,7 @@ class MemberServiceTest extends TestCase {
}
public function testSearchReturnsEmptyForNoMatch(): void {
$this->memberMapper->method('search')->willReturn([]);
$this->memberMapper->method('searchWithRelations')->willReturn([]);
$result = $this->service->search('Nonexistent');
$this->assertSame([], $result);
@@ -922,14 +925,15 @@ class MemberServiceTest extends TestCase {
public function testFullTextSearchReturnsResultsWithMatchContext(): void {
$member = $this->createMember(1, 'Max', 'Mustermann');
$address = $this->createAddress(1, 1);
$memberData = $member->jsonSerialize() + [
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
'phones' => [],
'emails' => [],
];
$this->memberMapper->method('fullTextSearch')
$this->memberMapper->method('fullTextSearchWithRelations')
->with('Max', 20)
->willReturn([$member]);
$this->addressMapper->method('findByMemberId')->willReturn([$address]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
->willReturn([$memberData]);
$result = $this->service->fullTextSearch('Max');
@@ -940,13 +944,14 @@ class MemberServiceTest extends TestCase {
public function testFullTextSearchMatchesAddress(): void {
$member = $this->createMember(1, 'Max', 'Mustermann');
$address = $this->createAddress(1, 1);
$memberData = $member->jsonSerialize() + [
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstrasse 1', 'plz' => '12345', 'ort' => 'Musterstadt', 'land' => 'Deutschland', 'isPrimary' => true]],
'phones' => [],
'emails' => [],
];
$this->memberMapper->method('fullTextSearch')
->willReturn([$member]);
$this->addressMapper->method('findByMemberId')->willReturn([$address]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
$this->memberMapper->method('fullTextSearchWithRelations')
->willReturn([$memberData]);
$result = $this->service->fullTextSearch('Musterstrasse');
@@ -957,11 +962,13 @@ class MemberServiceTest extends TestCase {
public function testFullTextSearchMatchesNotizen(): void {
$member = $this->createMember(1, 'Max', 'Mustermann');
$member->setNotizen('Hat einen besonderen Wunsch fuer Training');
$memberData = $member->jsonSerialize() + [
'addresses' => [],
'phones' => [],
'emails' => [],
];
$this->memberMapper->method('fullTextSearch')->willReturn([$member]);
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
$this->memberMapper->method('fullTextSearchWithRelations')->willReturn([$memberData]);
$result = $this->service->fullTextSearch('Training');
@@ -972,11 +979,13 @@ class MemberServiceTest extends TestCase {
public function testFullTextSearchMatchesZusatzNotizen(): void {
$member = $this->createMember(1, 'Max', 'Mustermann');
$member->setZusatzNotizen('Zusaetzliche Information');
$memberData = $member->jsonSerialize() + [
'addresses' => [],
'phones' => [],
'emails' => [],
];
$this->memberMapper->method('fullTextSearch')->willReturn([$member]);
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
$this->memberMapper->method('fullTextSearchWithRelations')->willReturn([$memberData]);
// Search for something not in name or notizen, but in zusatzNotizen
$result = $this->service->fullTextSearch('zusaetzliche');
@@ -991,7 +1000,7 @@ class MemberServiceTest extends TestCase {
$currentMonth = (int)(new \DateTime())->format('m');
$this->memberMapper->expects($this->once())
->method('findByBirthdayMonth')
->method('findByBirthdayMonthWithRelations')
->with($currentMonth)
->willReturn([]);
@@ -1000,10 +1009,9 @@ class MemberServiceTest extends TestCase {
public function testFindByBirthdayThisMonthReturnsMembers(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findByBirthdayMonth')->willReturn([$member]);
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
$this->memberMapper->method('findByBirthdayMonthWithRelations')->willReturn([
$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
]);
$result = $this->service->findByBirthdayThisMonth();
@@ -1016,7 +1024,7 @@ class MemberServiceTest extends TestCase {
$currentYear = (int)(new \DateTime())->format('Y');
$this->memberMapper->expects($this->once())
->method('findWithUnpaidFees')
->method('findWithUnpaidFeesWithRelations')
->with($currentYear)
->willReturn([]);
@@ -1025,10 +1033,9 @@ class MemberServiceTest extends TestCase {
public function testFindWithUnpaidFeesReturnsMembers(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findWithUnpaidFees')->willReturn([$member]);
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
$this->memberMapper->method('findWithUnpaidFeesWithRelations')->willReturn([
$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
]);
$result = $this->service->findWithUnpaidFees();
@@ -1039,7 +1046,7 @@ class MemberServiceTest extends TestCase {
public function testFindFilteredDelegatesToMapper(): void {
$this->memberMapper->expects($this->once())
->method('findFiltered')
->method('findFilteredWithRelations')
->with('aktiv', 'mitglied', true, false)
->willReturn([]);
@@ -1048,7 +1055,7 @@ class MemberServiceTest extends TestCase {
public function testFindFilteredNoFilters(): void {
$this->memberMapper->expects($this->once())
->method('findFiltered')
->method('findFilteredWithRelations')
->with(null, null, false, false)
->willReturn([]);
@@ -1058,12 +1065,13 @@ class MemberServiceTest extends TestCase {
public function testFindFilteredReturnsWithSubEntities(): void {
$member = $this->createMember(1);
$address = $this->createAddress(1, 1);
$memberData = $member->jsonSerialize() + [
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
'phones' => [],
'emails' => [],
];
$this->memberMapper->method('findFiltered')->willReturn([$member]);
$this->addressMapper->method('findByMemberId')->with(1)->willReturn([$address]);
$this->phoneMapper->method('findByMemberId')->with(1)->willReturn([]);
$this->emailMapper->method('findByMemberId')->with(1)->willReturn([]);
$this->memberMapper->method('findFilteredWithRelations')->willReturn([$memberData]);
$result = $this->service->findFiltered('aktiv');
@@ -1075,12 +1083,9 @@ class MemberServiceTest extends TestCase {
public function testFindFilteredByRolleOnly(): void {
$member = $this->createMember(1, rolle: 'erziehungsberechtigter');
$this->memberMapper->method('findFiltered')
$this->memberMapper->method('findFilteredWithRelations')
->with(null, 'erziehungsberechtigter', false, false)
->willReturn([$member]);
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
->willReturn([$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []]]);
$result = $this->service->findFiltered(null, 'erziehungsberechtigter');
@@ -1090,7 +1095,7 @@ class MemberServiceTest extends TestCase {
public function testFindFilteredCombinedAllParams(): void {
$this->memberMapper->expects($this->once())
->method('findFiltered')
->method('findFilteredWithRelations')
->with('inaktiv', 'mitglied', true, true)
->willReturn([]);
@@ -1252,10 +1257,9 @@ class MemberServiceTest extends TestCase {
public function testFindByFamilyReturnsMembers(): void {
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv', 'mitglied', 5);
$this->memberMapper->method('findByFamily')->with(5)->willReturn([$member]);
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
$this->memberMapper->method('findByFamilyWithRelations')->with(5)->willReturn([
$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
]);
$result = $this->service->findByFamily(5);
@@ -1268,10 +1272,9 @@ class MemberServiceTest extends TestCase {
public function testFindByStatusReturnsMembers(): void {
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv');
$this->memberMapper->method('findByStatus')->with('aktiv')->willReturn([$member]);
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
$this->memberMapper->method('findByStatusWithRelations')->with('aktiv')->willReturn([
$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
]);
$result = $this->service->findByStatus('aktiv');
@@ -1280,17 +1283,20 @@ class MemberServiceTest extends TestCase {
// ── findArchived() ──────────────────────────────────────────────
public function testFindArchivedReturnsOnlyDeletedMembers(): void {
$activeMember = $this->createMember(1);
$deletedMember = $this->createMember(2, 'Anna', 'Schmidt');
public function testFindArchivedDelegatesToMapper(): void {
// Issue #202: findArchived() now delegates to MemberMapper::findArchived()
// which uses a SQL WHERE deleted_at IS NOT NULL query instead of
// loading all members into memory and filtering in PHP.
$deletedMember = $this->createMember(1, 'Anna', 'Schmidt');
$deletedMember->setDeletedAt('2026-01-01 00:00:00');
$deletedMember->setStatus('geloescht');
$deletedMember->setEintritt('2018-01-01');
$deletedMember->setAustritt('2025-12-31');
$this->memberMapper->method('findAll')
->with(null, null, true)
->willReturn([$activeMember, $deletedMember]);
$this->memberMapper->expects($this->once())
->method('findArchived')
->with(null, null)
->willReturn([$deletedMember]);
$result = $this->service->findArchived();
@@ -1300,17 +1306,443 @@ class MemberServiceTest extends TestCase {
$this->assertArrayHasKey('deletedAt', $result[0]);
}
public function testFindArchivedPassesPaginationToMapper(): void {
$this->memberMapper->expects($this->once())
->method('findArchived')
->with(10, 20)
->willReturn([]);
$this->service->findArchived(10, 20);
}
public function testFindArchivedReturnsEmptyArrayWhenNoArchivedMembers(): void {
$this->memberMapper->method('findArchived')->willReturn([]);
$result = $this->service->findArchived();
$this->assertSame([], $result);
}
public function testFindArchivedReturnsOnlyRetainedFields(): void {
$deletedMember = $this->createMember(1, 'Anna', 'Schmidt');
$deletedMember->setDeletedAt('2026-01-01 00:00:00');
$deletedMember->setEintritt('2018-01-01');
$deletedMember->setAustritt('2025-12-31');
$this->memberMapper->method('findArchived')->willReturn([$deletedMember]);
$result = $this->service->findArchived();
$this->assertCount(1, $result);
$entry = $result[0];
// Retained fields should be present
$this->assertArrayHasKey('id', $entry);
$this->assertArrayHasKey('vorname', $entry);
$this->assertArrayHasKey('nachname', $entry);
$this->assertArrayHasKey('geburtsdatum', $entry);
$this->assertArrayHasKey('eintritt', $entry);
$this->assertArrayHasKey('austritt', $entry);
$this->assertArrayHasKey('deletedAt', $entry);
$this->assertArrayHasKey('mitgliedsdauer', $entry);
// Sensitive fields should NOT be present
$this->assertArrayNotHasKey('notizen', $entry);
$this->assertArrayNotHasKey('allergienEncrypted', $entry);
$this->assertArrayNotHasKey('kvTyp', $entry);
}
// ── countArchived() ──────────────────────────────────────────────
public function testCountArchivedReturnsCorrectCount(): void {
$activeMember = $this->createMember(1);
$deletedMember = $this->createMember(2);
$deletedMember->setDeletedAt('2026-01-01 00:00:00');
$this->memberMapper->method('findAll')
->with(null, null, true)
->willReturn([$activeMember, $deletedMember]);
// Issue #201: countArchived() now delegates to MemberMapper::countArchived()
// which uses a SQL COUNT query instead of loading all members into memory.
$this->memberMapper->method('countArchived')->willReturn(1);
$this->assertSame(1, $this->service->countArchived());
}
public function testCountArchivedReturnsZeroWhenNoArchived(): void {
$this->memberMapper->method('countArchived')->willReturn(0);
$this->assertSame(0, $this->service->countArchived());
}
// ── Joined methods: findAllWithRelations ──────────────────────
public function testFindAllWithRelationsReturnsMemberWithNestedSubEntities(): void {
$member = $this->createMember(1, 'Max', 'Mustermann');
$memberData = $member->jsonSerialize() + [
'addresses' => [
['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'strasse' => 'Hauptstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true],
['id' => 2, 'memberId' => 1, 'label' => 'Arbeit', 'strasse' => 'Büroweg 5', 'plz' => '12346', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => false],
],
'phones' => [
['id' => 1, 'memberId' => 1, 'label' => 'Mobil', 'numberE164' => '+4917612345678'],
],
'emails' => [
['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'email' => 'max@example.com'],
],
];
$this->memberMapper->method('findAllWithRelations')->willReturn([$memberData]);
$result = $this->service->findAll();
$this->assertCount(1, $result);
$this->assertSame('Max', $result[0]['vorname']);
$this->assertCount(2, $result[0]['addresses']);
$this->assertCount(1, $result[0]['phones']);
$this->assertCount(1, $result[0]['emails']);
$this->assertSame('Hauptstr. 1', $result[0]['addresses'][0]['strasse']);
$this->assertSame('+4917612345678', $result[0]['phones'][0]['numberE164']);
$this->assertSame('max@example.com', $result[0]['emails'][0]['email']);
}
public function testFindAllWithRelationsHandlesMemberWithoutSubEntities(): void {
$member = $this->createMember(1);
$memberData = $member->jsonSerialize() + [
'addresses' => [],
'phones' => [],
'emails' => [],
];
$this->memberMapper->method('findAllWithRelations')->willReturn([$memberData]);
$result = $this->service->findAll();
$this->assertCount(1, $result);
$this->assertCount(0, $result[0]['addresses']);
$this->assertCount(0, $result[0]['phones']);
$this->assertCount(0, $result[0]['emails']);
}
// ── Joined methods: searchWithRelations ───────────────────────
public function testSearchWithRelationsReturnsMemberWithSubEntities(): void {
$member = $this->createMember(1, 'Max', 'Mustermann');
$memberData = $member->jsonSerialize() + [
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
'phones' => [],
'emails' => [],
];
$this->memberMapper->method('searchWithRelations')->with('Max')->willReturn([$memberData]);
$result = $this->service->search('Max');
$this->assertCount(1, $result);
$this->assertSame('Max', $result[0]['vorname']);
$this->assertCount(1, $result[0]['addresses']);
}
// ── Joined methods: findByFamilyWithRelations ─────────────────
public function testFindByFamilyWithRelationsReturnsMemberWithSubEntities(): void {
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv', 'mitglied', 5);
$memberData = $member->jsonSerialize() + [
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
'phones' => [['id' => 1, 'memberId' => 1, 'label' => 'Mobil', 'numberE164' => '+4917612345678']],
'emails' => [['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'email' => 'max@example.com']],
];
$this->memberMapper->method('findByFamilyWithRelations')->with(5)->willReturn([$memberData]);
$result = $this->service->findByFamily(5);
$this->assertCount(1, $result);
$this->assertSame('Max', $result[0]['vorname']);
$this->assertCount(1, $result[0]['addresses']);
$this->assertCount(1, $result[0]['phones']);
$this->assertCount(1, $result[0]['emails']);
}
// ── Joined methods: findByStatusWithRelations ─────────────────
public function testFindByStatusWithRelationsReturnsMemberWithSubEntities(): void {
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv');
$memberData = $member->jsonSerialize() + [
'addresses' => [],
'phones' => [],
'emails' => [],
];
$this->memberMapper->method('findByStatusWithRelations')->with('aktiv')->willReturn([$memberData]);
$result = $this->service->findByStatus('aktiv');
$this->assertCount(1, $result);
$this->assertSame('Max', $result[0]['vorname']);
}
// ── Joined methods: findByBirthdayMonthWithRelations ──────────
public function testFindByBirthdayMonthWithRelationsReturnsMemberWithSubEntities(): void {
$currentMonth = (int)(new \DateTime())->format('m');
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-06-15', '2020-01-01');
$memberData = $member->jsonSerialize() + [
'addresses' => [],
'phones' => [],
'emails' => [],
];
$this->memberMapper->expects($this->once())
->method('findByBirthdayMonthWithRelations')
->with($currentMonth)
->willReturn([$memberData]);
$result = $this->service->findByBirthdayThisMonth();
$this->assertCount(1, $result);
$this->assertSame('Max', $result[0]['vorname']);
}
// ── Joined methods: findWithUnpaidFeesWithRelations ───────────
public function testFindWithUnpaidFeesWithRelationsReturnsMemberWithSubEntities(): void {
$member = $this->createMember(1, 'Max', 'Mustermann');
$memberData = $member->jsonSerialize() + [
'addresses' => [],
'phones' => [],
'emails' => [],
];
$this->memberMapper->method('findWithUnpaidFeesWithRelations')->willReturn([$memberData]);
$result = $this->service->findWithUnpaidFees();
$this->assertCount(1, $result);
$this->assertSame('Max', $result[0]['vorname']);
}
// ── Joined methods: findFilteredWithRelations ─────────────────
public function testFindFilteredWithRelationsReturnsMemberWithSubEntities(): void {
$member = $this->createMember(1, 'Max', 'Mustermann');
$memberData = $member->jsonSerialize() + [
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
'phones' => [],
'emails' => [],
];
$this->memberMapper->method('findFilteredWithRelations')
->with('aktiv', 'mitglied', false, false)
->willReturn([$memberData]);
$result = $this->service->findFiltered('aktiv', 'mitglied');
$this->assertCount(1, $result);
$this->assertSame('Max', $result[0]['vorname']);
$this->assertCount(1, $result[0]['addresses']);
}
// ── Joined methods: fullTextSearchWithRelations ───────────────
public function testFullTextSearchWithRelationsReturnsResultsWithMatchContext(): void {
$member = $this->createMember(1, 'Max', 'Mustermann');
$memberData = $member->jsonSerialize() + [
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstrasse 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
'phones' => [],
'emails' => [],
];
$this->memberMapper->method('fullTextSearchWithRelations')
->with('Musterstrasse', 20)
->willReturn([$memberData]);
$result = $this->service->fullTextSearch('Musterstrasse');
$this->assertCount(1, $result);
$this->assertArrayHasKey('matchContext', $result[0]);
$this->assertStringContainsString('Adresse:', $result[0]['matchContext']);
$this->assertStringContainsString('Musterstrasse 1', $result[0]['matchContext']);
}
// ── Backward compatibility: findAll shape ─────────────────────
public function testFindAllReturnsSameShapeAsBefore(): void {
// The new method returns the same shape as the old one:
// an array of member arrays with addresses/phones/emails sub-arrays
$member = $this->createMember(1, 'Max');
$memberData = $member->jsonSerialize() + [
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Hauptstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
'phones' => [['id' => 1, 'memberId' => 1, 'label' => 'Mobil', 'numberE164' => '+4917612345678']],
'emails' => [['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'email' => 'max@example.com']],
];
$this->memberMapper->method('findAllWithRelations')->willReturn([$memberData]);
$result = $this->service->findAll();
// Verify the shape matches what the frontend expects
$this->assertIsArray($result[0]);
$this->assertArrayHasKey('id', $result[0]);
$this->assertArrayHasKey('vorname', $result[0]);
$this->assertArrayHasKey('nachname', $result[0]);
$this->assertArrayHasKey('addresses', $result[0]);
$this->assertArrayHasKey('phones', $result[0]);
$this->assertArrayHasKey('emails', $result[0]);
}
// ── Database Transactions ───────────────────────────────────────
public function testCreateCommitsTransactionOnSuccess(): void {
$data = $this->getValidMemberData();
$this->memberMapper->method('search')->willReturn([]);
$this->memberMapper->method('insert')
->willReturnCallback(function (Member $m) {
$m->setId(1);
return $m;
});
$this->db->expects($this->once())->method('beginTransaction');
$this->db->expects($this->once())->method('commit');
$this->db->expects($this->never())->method('rollback');
$this->service->create($data);
}
public function testCreateRollbacksTransactionWhenSubEntityInsertFails(): void {
$data = $this->getValidMemberData();
$phones = [['numberE164' => '+4917612345678']];
$this->memberMapper->method('search')->willReturn([]);
$this->memberMapper->method('insert')
->willReturnCallback(function (Member $m) {
$m->setId(1);
return $m;
});
$this->phoneMapper->method('insert')->willThrowException(new \RuntimeException('DB error'));
$this->db->expects($this->once())->method('beginTransaction');
$this->db->expects($this->once())->method('rollback');
$this->db->expects($this->never())->method('commit');
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('DB error');
$this->service->create($data, [], $phones, []);
}
public function testCreateRollbacksTransactionWhenAddressInsertFails(): void {
$data = $this->getValidMemberData();
$addresses = [['strasse' => 'Hauptstr. 1', 'plz' => '12345', 'ort' => 'Berlin']];
$this->memberMapper->method('search')->willReturn([]);
$this->memberMapper->method('insert')
->willReturnCallback(function (Member $m) {
$m->setId(1);
return $m;
});
$this->addressMapper->method('insert')->willThrowException(new \RuntimeException('Constraint violation'));
$this->db->expects($this->once())->method('beginTransaction');
$this->db->expects($this->once())->method('rollback');
$this->db->expects($this->never())->method('commit');
$this->expectException(\RuntimeException::class);
$this->service->create($data, $addresses, [], []);
}
public function testSoftDeleteCommitsTransactionOnSuccess(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);
$this->db->expects($this->once())->method('beginTransaction');
$this->db->expects($this->once())->method('commit');
$this->db->expects($this->never())->method('rollback');
$this->service->softDelete(1);
}
public function testSoftDeleteRollbacksTransactionWhenDeleteFails(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);
$this->phoneMapper->method('deleteByMemberId')->willThrowException(new \RuntimeException('DB error'));
$this->db->expects($this->once())->method('beginTransaction');
$this->db->expects($this->once())->method('rollback');
$this->db->expects($this->never())->method('commit');
$this->expectException(\RuntimeException::class);
$this->service->softDelete(1);
}
public function testUpdateCommitsTransactionOnSuccess(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
$this->db->expects($this->once())->method('beginTransaction');
$this->db->expects($this->once())->method('commit');
$this->db->expects($this->never())->method('rollback');
$this->service->update(1, ['notizen' => 'Test']);
}
public function testUpdateRollbacksTransactionWhenSubEntitySyncFails(): void {
$member = $this->createMember(1);
$existing = new Address();
$existing->setId(42);
$existing->setMemberId(1);
$existing->setStrasse('Alt');
$existing->setPlz('00000');
$existing->setOrt('Alt');
$existing->setLand('Deutschland');
$existing->setIsPrimary(false);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);
$this->addressMapper->method('findByMemberId')->willReturn([$existing]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
// find() is called by syncAddresses -> updateAddress
$this->addressMapper->method('find')->with(42)->willReturn($existing);
$this->addressMapper->method('update')->willThrowException(new \RuntimeException('DB error'));
$this->db->expects($this->once())->method('beginTransaction');
$this->db->expects($this->once())->method('rollback');
$this->db->expects($this->never())->method('commit');
$this->expectException(\RuntimeException::class);
$this->service->update(1, [
'addresses' => [
['id' => 42, 'strasse' => 'Neu', 'plz' => '12345', 'ort' => 'Neu'],
],
]);
}
public function testUpdateRollbacksTransactionWhenPhoneSyncFails(): void {
$member = $this->createMember(1);
$this->memberMapper->method('findById')->with(1)->willReturn($member);
$this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);
$this->addressMapper->method('findByMemberId')->willReturn([]);
$this->phoneMapper->method('findByMemberId')->willReturn([]);
$this->emailMapper->method('findByMemberId')->willReturn([]);
// syncPhones will try to insert a new phone
$this->phoneMapper->method('insert')->willThrowException(new \RuntimeException('DB error'));
$this->db->expects($this->once())->method('beginTransaction');
$this->db->expects($this->once())->method('rollback');
$this->db->expects($this->never())->method('commit');
$this->expectException(\RuntimeException::class);
$this->service->update(1, [
'phones' => [['numberE164' => '+4917612345678']],
]);
}
}
+18 -5
View File
@@ -15,6 +15,7 @@ use OCA\Mitgliederverwaltung\Service\AuditService;
use OCA\Mitgliederverwaltung\Service\DuplicateMemberException;
use OCA\Mitgliederverwaltung\Service\MemberService;
use OCA\Mitgliederverwaltung\Service\ValidationException;
use OCP\IDBConnection;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
@@ -36,6 +37,7 @@ class MemberValidationTest extends TestCase {
private StufeHistoryMapper&MockObject $stufeHistoryMapper;
private AuditService&MockObject $auditService;
private LoggerInterface&MockObject $logger;
private IDBConnection&MockObject $db;
protected function setUp(): void {
parent::setUp();
@@ -48,6 +50,7 @@ class MemberValidationTest extends TestCase {
$this->stufeHistoryMapper = $this->createMock(StufeHistoryMapper::class);
$this->auditService = $this->createMock(AuditService::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->db = $this->createMock(IDBConnection::class);
$this->service = new MemberService(
$this->memberMapper,
@@ -57,7 +60,9 @@ class MemberValidationTest extends TestCase {
$this->familyMapper,
$this->stufeHistoryMapper,
$this->auditService,
$this->logger
$this->logger,
null,
$this->db
);
// Default: no duplicates found
@@ -256,7 +261,9 @@ class MemberValidationTest extends TestCase {
$this->familyMapper,
$this->stufeHistoryMapper,
$this->auditService,
$this->logger
$this->logger,
null,
$this->db
);
$this->expectException(DuplicateMemberException::class);
@@ -292,7 +299,9 @@ class MemberValidationTest extends TestCase {
$this->familyMapper,
$this->stufeHistoryMapper,
$this->auditService,
$this->logger
$this->logger,
null,
$this->db
);
$this->expectException(DuplicateMemberException::class);
@@ -338,7 +347,9 @@ class MemberValidationTest extends TestCase {
$this->familyMapper,
$this->stufeHistoryMapper,
$this->auditService,
$this->logger
$this->logger,
null,
$this->db
);
$this->addressMapper->method('findByMemberId')->willReturn([]);
@@ -377,7 +388,9 @@ class MemberValidationTest extends TestCase {
$this->familyMapper,
$this->stufeHistoryMapper,
$this->auditService,
$this->logger
$this->logger,
null,
$this->db
);
$this->expectException(DuplicateMemberException::class);
+27 -1
View File
@@ -19,4 +19,30 @@ if (!class_exists('OCA\DAV\CalDAV\CalDavBackend')) {
eval('namespace OCA\DAV\CalDAV; class CalDavBackend {}');
}
require_once __DIR__ . '/../vendor/autoload.php';
// Try the app's vendor autoload first (full local dev).
// Fall back to Nextcloud's root autoloader which has PSR packages.
$appAutoload = __DIR__ . '/../vendor/autoload.php';
$ncAutoload = '/var/www/html/lib/composer/autoload.php';
if (file_exists($appAutoload)) {
require_once $appAutoload;
} elseif (file_exists($ncAutoload)) {
require_once $ncAutoload;
}
// Register the app's lib directory for PSR-4 autoloading.
// This ensures OCA\Mitgliederverwaltung\* classes are loaded regardless
// of which vendor/autoload.php was used (full local dev vs. container).
spl_autoload_register(function ($class) {
$prefix = 'OCA\\Mitgliederverwaltung\\';
$baseDir = __DIR__ . '/../lib/';
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) {
return;
}
$relativeClass = substr($class, $len);
$file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
if (file_exists($file)) {
require $file;
}
});