Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0ad92dd47 | |||
| 19adf0ba87 | |||
| bfb57b4e1e | |||
| c4f5f8e7fb | |||
| 0ffc993d3f | |||
| e1d24d4d54 | |||
| 5c67fc763a | |||
| ccda3ee570 | |||
| d28dcd4541 | |||
| d4e25fe739 | |||
| 967bacf231 | |||
| ee569250ad | |||
| b29a268b1d |
+2
-3
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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
Binary file not shown.
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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']],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user