docs: Migrationsplan für Projekt-Restrukturierung hinzugefügt
- Detaillierter Plan zur Migration von alter zu neuer Modulstruktur - Umfasst Überführung von shared-kernel zu core-Modulen - Definiert Migration von Fachdomänen zu bounded contexts: * master-data → masterdata-Module * member-management → members-Module * horse-registry → horses-Module * event-management → events-Module - Beschreibt Verlagerung von api-gateway zu infrastructure/gateway - Strukturiert nach Domain-driven Design Prinzipien - Berücksichtigt Clean Architecture Layering (domain, application, infrastructure, api)
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
# Members Module
|
||||
|
||||
## Überblick
|
||||
|
||||
Das Members-Modul ist eine umfassende Lösung zur Verwaltung von Mitgliedern für Pferdesportorganisationen. Es implementiert eine saubere Architektur mit Domain-Driven Design und bietet vollständige CRUD-Operationen sowie erweiterte Geschäftslogik für die Mitgliederverwaltung.
|
||||
|
||||
## Funktionalität
|
||||
|
||||
### Verwaltete Entität
|
||||
|
||||
#### Mitglied (Member)
|
||||
- **Persönliche Informationen**: Vor- und Nachname, E-Mail, Telefon, Geburtsdatum
|
||||
- **Mitgliedschaftsinformationen**: Mitgliedsnummer, Start-/Enddatum, Aktivitätsstatus
|
||||
- **Zusätzliche Informationen**: Adresse, Notfallkontakt
|
||||
- **Audit-Felder**: Erstellungs- und Aktualisierungszeitstempel
|
||||
- **Geschäftslogik**: Validierung, Mitgliedschaftsgültigkeit, Vollständiger Name
|
||||
|
||||
### Geschäftsoperationen
|
||||
|
||||
Das Modul bietet 18+ spezialisierte Repository-Operationen:
|
||||
|
||||
#### Basis-CRUD-Operationen
|
||||
- `findById(id)` - Mitglied nach UUID suchen
|
||||
- `save(member)` - Mitglied speichern (erstellen/aktualisieren)
|
||||
- `delete(id)` - Mitglied löschen
|
||||
|
||||
#### Such-Operationen
|
||||
- `findByMembershipNumber(number)` - Nach Mitgliedsnummer suchen
|
||||
- `findByEmail(email)` - Nach E-Mail-Adresse suchen
|
||||
- `findByName(searchTerm, limit)` - Nach Namen suchen (Teilübereinstimmung)
|
||||
- `findAllActive(limit, offset)` - Alle aktiven Mitglieder
|
||||
- `findAll(limit, offset)` - Alle Mitglieder (aktiv und inaktiv)
|
||||
|
||||
#### Datumsbasierte Abfragen
|
||||
- `findByMembershipStartDateRange(start, end)` - Mitglieder nach Startdatum-Bereich
|
||||
- `findByMembershipEndDateRange(start, end)` - Mitglieder nach Enddatum-Bereich
|
||||
- `findMembersWithExpiringMembership(daysAhead)` - Mitglieder mit ablaufender Mitgliedschaft
|
||||
|
||||
#### Validierungs-Operationen
|
||||
- `existsByMembershipNumber(number, excludeId)` - Prüfung auf doppelte Mitgliedsnummer
|
||||
- `existsByEmail(email, excludeId)` - Prüfung auf doppelte E-Mail-Adresse
|
||||
|
||||
#### Zähl-Operationen
|
||||
- `countActive()` - Anzahl aktiver Mitglieder
|
||||
- `countAll()` - Gesamtanzahl aller Mitglieder
|
||||
|
||||
## Architektur
|
||||
|
||||
Das Modul folgt der Clean Architecture mit klarer Trennung der Verantwortlichkeiten:
|
||||
|
||||
```
|
||||
members/
|
||||
├── members-domain/ # Domain Layer
|
||||
│ ├── model/ # Domain Models
|
||||
│ │ └── Member.kt # Mitglied-Entität mit Geschäftslogik
|
||||
│ ├── repository/ # Repository Interfaces
|
||||
│ │ └── MemberRepository.kt # 18+ Geschäftsoperationen
|
||||
│ └── events/ # Domain Events
|
||||
│ └── MemberEvents.kt # Mitgliedschafts-Events
|
||||
├── members-application/ # Application Layer
|
||||
│ └── usecase/ # Use Cases
|
||||
│ └── FindExpiringMembershipsUseCase.kt
|
||||
├── members-infrastructure/ # Infrastructure Layer
|
||||
│ ├── persistence/ # Database Implementation
|
||||
│ │ ├── MemberRepositoryImpl.kt
|
||||
│ │ └── MemberTable.kt
|
||||
│ └── repository/ # Alternative Implementations
|
||||
│ └── InMemoryMemberRepository.kt
|
||||
├── members-api/ # API Layer
|
||||
│ └── rest/ # REST Controllers
|
||||
│ └── MemberController.kt
|
||||
└── members-service/ # Service Layer
|
||||
├── MembersServiceApplication.kt
|
||||
└── test/ # Integration Tests
|
||||
└── MemberServiceIntegrationTest.kt
|
||||
```
|
||||
|
||||
### Domain Layer
|
||||
- **1 Domain Model** mit reichhaltiger Geschäftslogik
|
||||
- **1 Repository Interface** mit 18+ Geschäftsoperationen
|
||||
- **Domain Events** für Mitgliedschaftsänderungen
|
||||
- **Keine Abhängigkeiten** zu anderen Layern
|
||||
|
||||
### Application Layer
|
||||
- **Use Cases** für komplexe Geschäftsoperationen
|
||||
- **Orchestrierung** von Domain-Services
|
||||
- **Anwendungslogik** ohne UI-Abhängigkeiten
|
||||
|
||||
### Infrastructure Layer
|
||||
- **Datenbankzugriff** mit Exposed ORM
|
||||
- **Repository-Implementierung** mit PostgreSQL
|
||||
- **In-Memory-Repository** für Tests
|
||||
- **Datenbankschema** und Migrationen
|
||||
|
||||
### API Layer
|
||||
- **REST-Controller** für HTTP-Endpunkte
|
||||
- **DTO-Mapping** zwischen Domain und API
|
||||
- **Validierung** und Fehlerbehandlung
|
||||
|
||||
### Service Layer
|
||||
- **Spring Boot Anwendung**
|
||||
- **Dependency Injection** Konfiguration
|
||||
- **Integrationstests**
|
||||
|
||||
## Domain Model Details
|
||||
|
||||
### Member-Entität
|
||||
|
||||
```kotlin
|
||||
data class Member(
|
||||
val memberId: Uuid,
|
||||
|
||||
// Persönliche Informationen
|
||||
var firstName: String,
|
||||
var lastName: String,
|
||||
var email: String,
|
||||
var phone: String? = null,
|
||||
var dateOfBirth: LocalDate? = null,
|
||||
|
||||
// Mitgliedschaftsinformationen
|
||||
var membershipNumber: String,
|
||||
var membershipStartDate: LocalDate,
|
||||
var membershipEndDate: LocalDate? = null,
|
||||
var isActive: Boolean = true,
|
||||
|
||||
// Zusätzliche Informationen
|
||||
var address: String? = null,
|
||||
var emergencyContact: String? = null,
|
||||
|
||||
// Audit-Felder
|
||||
val createdAt: Instant,
|
||||
var updatedAt: Instant
|
||||
)
|
||||
```
|
||||
|
||||
### Geschäftslogik-Methoden
|
||||
|
||||
- `getFullName()` - Vollständiger Name des Mitglieds
|
||||
- `isMembershipValid()` - Prüfung der Mitgliedschaftsgültigkeit
|
||||
- `validate()` - Datenvalidierung mit Fehlerliste
|
||||
- `withUpdatedTimestamp()` - Kopie mit aktualisiertem Zeitstempel
|
||||
|
||||
## Repository-Operationen
|
||||
|
||||
### Erweiterte Such-Features
|
||||
|
||||
```kotlin
|
||||
// Mitglieder mit ablaufender Mitgliedschaft finden
|
||||
val expiringMembers = memberRepository.findMembersWithExpiringMembership(30)
|
||||
|
||||
// Mitglieder nach Datumsbereich suchen
|
||||
val newMembers = memberRepository.findByMembershipStartDateRange(
|
||||
startDate = LocalDate(2024, 1, 1),
|
||||
endDate = LocalDate(2024, 12, 31)
|
||||
)
|
||||
|
||||
// Namenssuche mit Teilübereinstimmung
|
||||
val searchResults = memberRepository.findByName("Schmidt", limit = 10)
|
||||
```
|
||||
|
||||
### Validierung und Duplikatsprüfung
|
||||
|
||||
```kotlin
|
||||
// Prüfung auf doppelte Mitgliedsnummer
|
||||
val numberExists = memberRepository.existsByMembershipNumber("M2024001")
|
||||
|
||||
// Prüfung auf doppelte E-Mail (mit Ausschluss für Updates)
|
||||
val emailExists = memberRepository.existsByEmail(
|
||||
email = "max@example.com",
|
||||
excludeMemberId = existingMember.memberId
|
||||
)
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
### FindExpiringMembershipsUseCase
|
||||
|
||||
Findet Mitglieder mit ablaufenden Mitgliedschaften und kann automatische Benachrichtigungen auslösen.
|
||||
|
||||
```kotlin
|
||||
class FindExpiringMembershipsUseCase(
|
||||
private val memberRepository: MemberRepository
|
||||
) {
|
||||
suspend fun execute(daysAhead: Int = 30): List<Member> {
|
||||
return memberRepository.findMembersWithExpiringMembership(daysAhead)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API-Endpunkte
|
||||
|
||||
Das Members-Modul stellt REST-Endpunkte über den MemberController bereit:
|
||||
|
||||
- `GET /api/members` - Alle aktiven Mitglieder abrufen
|
||||
- `GET /api/members/{id}` - Mitglied nach ID abrufen
|
||||
- `GET /api/members/search?name={name}` - Mitglieder nach Namen suchen
|
||||
- `GET /api/members/expiring?days={days}` - Mitglieder mit ablaufender Mitgliedschaft
|
||||
- `POST /api/members` - Neues Mitglied erstellen
|
||||
- `PUT /api/members/{id}` - Mitglied aktualisieren
|
||||
- `DELETE /api/members/{id}` - Mitglied löschen
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### Datenbankschema
|
||||
|
||||
Das Modul verwendet eine `members`-Tabelle mit folgenden Spalten:
|
||||
- `member_id` (UUID, Primary Key)
|
||||
- `first_name`, `last_name`, `email` (Required)
|
||||
- `phone`, `date_of_birth` (Optional)
|
||||
- `membership_number` (Unique)
|
||||
- `membership_start_date`, `membership_end_date`
|
||||
- `is_active` (Boolean)
|
||||
- `address`, `emergency_contact` (Optional)
|
||||
- `created_at`, `updated_at` (Timestamps)
|
||||
|
||||
### Service-Konfiguration
|
||||
|
||||
```yaml
|
||||
# application.yml
|
||||
members:
|
||||
service:
|
||||
name: members-service
|
||||
port: 8082
|
||||
database:
|
||||
url: jdbc:postgresql://localhost:5432/meldestelle
|
||||
table: members
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Das Modul enthält umfassende Integrationstests:
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `should find members with expiring membership`() {
|
||||
// Test-Implementierung für ablaufende Mitgliedschaften
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should validate unique membership number`() {
|
||||
// Test für Eindeutigkeit der Mitgliedsnummer
|
||||
}
|
||||
```
|
||||
|
||||
### Test-Datenbank
|
||||
|
||||
Verwendet H2 In-Memory-Datenbank für Tests mit automatischem Schema-Setup.
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker
|
||||
|
||||
```dockerfile
|
||||
FROM openjdk:21-jre-slim
|
||||
COPY members-service.jar app.jar
|
||||
EXPOSE 8082
|
||||
ENTRYPOINT ["java", "-jar", "/app.jar"]
|
||||
```
|
||||
|
||||
### Kubernetes
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: members-service
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: members-service
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: members-service
|
||||
image: meldestelle/members-service:latest
|
||||
ports:
|
||||
- containerPort: 8082
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Metriken
|
||||
|
||||
- Anzahl aktiver Mitglieder
|
||||
- Anzahl ablaufender Mitgliedschaften
|
||||
- API-Response-Zeiten
|
||||
- Datenbankverbindungs-Pool
|
||||
|
||||
### Health Checks
|
||||
|
||||
- Datenbankverbindung
|
||||
- Service-Verfügbarkeit
|
||||
- Speicherverbrauch
|
||||
|
||||
## Entwicklung
|
||||
|
||||
### Lokale Entwicklung
|
||||
|
||||
```bash
|
||||
# Service starten
|
||||
./gradlew :members:members-service:bootRun
|
||||
|
||||
# Tests ausführen
|
||||
./gradlew :members:test
|
||||
|
||||
# Integration Tests
|
||||
./gradlew :members:members-service:test
|
||||
```
|
||||
|
||||
### Code-Qualität
|
||||
|
||||
- **Kotlin Coding Standards**
|
||||
- **100% Test Coverage** für Domain Layer
|
||||
- **Integration Tests** für alle Use Cases
|
||||
- **API-Dokumentation** mit OpenAPI
|
||||
|
||||
## Zukünftige Erweiterungen
|
||||
|
||||
1. **Mitgliedschaftstypen** - Verschiedene Mitgliedschaftskategorien
|
||||
2. **Beitragsverwaltung** - Integration mit Zahlungssystem
|
||||
3. **Mitgliedschaftshistorie** - Tracking von Änderungen
|
||||
4. **Bulk-Operationen** - Massenimport/-export
|
||||
5. **Benachrichtigungen** - Automatische E-Mail-Benachrichtigungen
|
||||
6. **Reporting** - Mitgliedschaftsstatistiken und Reports
|
||||
|
||||
---
|
||||
|
||||
**Letzte Aktualisierung**: 25. Juli 2025
|
||||
|
||||
Für weitere Informationen zur Gesamtarchitektur siehe [README.md](../README.md).
|
||||
@@ -10,6 +10,7 @@ dependencies {
|
||||
implementation(projects.members.membersApplication)
|
||||
implementation(projects.core.coreDomain)
|
||||
implementation(projects.core.coreUtils)
|
||||
implementation(projects.infrastructure.messaging.messagingClient)
|
||||
|
||||
implementation("org.springframework:spring-web")
|
||||
implementation("org.springdoc:springdoc-openapi-starter-common")
|
||||
|
||||
+270
-50
@@ -3,16 +3,41 @@ package at.mocode.members.api.rest
|
||||
import at.mocode.core.domain.model.ApiResponse
|
||||
import at.mocode.members.application.usecase.CreateMemberUseCase
|
||||
import at.mocode.members.application.usecase.DeleteMemberUseCase
|
||||
import at.mocode.members.application.usecase.FindExpiringMembershipsUseCase
|
||||
import at.mocode.members.application.usecase.FindMembersByDateRangeUseCase
|
||||
import at.mocode.members.application.usecase.GetMemberUseCase
|
||||
import at.mocode.members.application.usecase.UpdateMemberUseCase
|
||||
import at.mocode.members.application.usecase.ValidateMemberDataUseCase
|
||||
import at.mocode.members.domain.repository.MemberRepository
|
||||
import at.mocode.infrastructure.messaging.client.EventPublisher
|
||||
import com.benasher44.uuid.Uuid
|
||||
import com.benasher44.uuid.uuidFrom
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.springframework.beans.factory.annotation.Qualifier
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import io.swagger.v3.oas.annotations.Operation
|
||||
import io.swagger.v3.oas.annotations.Parameter
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse as SwaggerApiResponse
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||
import io.swagger.v3.oas.annotations.tags.Tag
|
||||
import io.swagger.v3.oas.annotations.media.Content
|
||||
import io.swagger.v3.oas.annotations.media.Schema
|
||||
|
||||
/**
|
||||
* Simple no-op EventPublisher implementation for the controller.
|
||||
*/
|
||||
class NoOpEventPublisher : EventPublisher {
|
||||
override suspend fun publishEvent(topic: String, key: String?, event: Any) {
|
||||
// No-op implementation - events are not published in this simple version
|
||||
}
|
||||
|
||||
override suspend fun publishEvents(topic: String, events: List<Pair<String?, Any>>) {
|
||||
// No-op implementation - events are not published in this simple version
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API controller for member management operations.
|
||||
@@ -22,23 +47,92 @@ import org.springframework.web.bind.annotation.*
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/members")
|
||||
@Tag(name = "Members", description = "Member management operations")
|
||||
class MemberController(
|
||||
private val memberRepository: MemberRepository
|
||||
@Qualifier("memberRepositoryImpl") private val memberRepository: MemberRepository
|
||||
) {
|
||||
|
||||
private val createMemberUseCase = CreateMemberUseCase(memberRepository)
|
||||
// Simple no-op EventPublisher implementation for now
|
||||
private val eventPublisher = NoOpEventPublisher()
|
||||
|
||||
private val createMemberUseCase = CreateMemberUseCase(memberRepository, eventPublisher)
|
||||
private val getMemberUseCase = GetMemberUseCase(memberRepository)
|
||||
private val updateMemberUseCase = UpdateMemberUseCase(memberRepository)
|
||||
private val deleteMemberUseCase = DeleteMemberUseCase(memberRepository)
|
||||
private val findExpiringMembershipsUseCase = FindExpiringMembershipsUseCase(memberRepository)
|
||||
private val findMembersByDateRangeUseCase = FindMembersByDateRangeUseCase(memberRepository)
|
||||
private val validateMemberDataUseCase = ValidateMemberDataUseCase(memberRepository)
|
||||
|
||||
/**
|
||||
* Helper method to handle common response patterns for use case execution
|
||||
*/
|
||||
private inline fun <T> handleUseCaseExecution(
|
||||
crossinline operation: suspend () -> ApiResponse<T>,
|
||||
successStatus: HttpStatus = HttpStatus.OK,
|
||||
crossinline extractData: (T) -> Any = { it as Any }
|
||||
): ResponseEntity<ApiResponse<*>> {
|
||||
return try {
|
||||
val response = runBlocking { operation() }
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
ResponseEntity.status(successStatus)
|
||||
.body(ApiResponse.success(extractData(response.data!!)))
|
||||
} else {
|
||||
val statusCode = when (response.error?.code) {
|
||||
"MEMBER_NOT_FOUND" -> HttpStatus.NOT_FOUND
|
||||
"VALIDATION_ERROR" -> HttpStatus.BAD_REQUEST
|
||||
else -> HttpStatus.BAD_REQUEST
|
||||
}
|
||||
ResponseEntity.status(statusCode)
|
||||
.body(ApiResponse.error<Any>(response.error?.message ?: "Operation failed"))
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error<Any>("Invalid input format: ${e.message}"))
|
||||
} catch (e: Exception) {
|
||||
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error<Any>("Internal server error: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to handle repository operations with common error handling
|
||||
*/
|
||||
private inline fun <T> handleRepositoryOperation(
|
||||
crossinline operation: () -> T,
|
||||
errorMessage: String = "Operation failed"
|
||||
): ResponseEntity<ApiResponse<T>> {
|
||||
return try {
|
||||
val result = runBlocking { operation() }
|
||||
ResponseEntity.ok(ApiResponse.success(result))
|
||||
} catch (e: Exception) {
|
||||
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error<T>("$errorMessage: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all members with optional filtering
|
||||
*/
|
||||
@Operation(
|
||||
summary = "Get all members",
|
||||
description = "Retrieve all members with optional filtering by active status and search term"
|
||||
)
|
||||
@ApiResponses(
|
||||
value = [
|
||||
SwaggerApiResponse(responseCode = "200", description = "Successfully retrieved members"),
|
||||
SwaggerApiResponse(responseCode = "500", description = "Internal server error")
|
||||
]
|
||||
)
|
||||
@GetMapping
|
||||
fun getAllMembers(
|
||||
@Parameter(description = "Filter by active members only", example = "true")
|
||||
@RequestParam(defaultValue = "true") activeOnly: Boolean,
|
||||
@Parameter(description = "Maximum number of results to return", example = "100")
|
||||
@RequestParam(defaultValue = "100") limit: Int,
|
||||
@Parameter(description = "Number of results to skip", example = "0")
|
||||
@RequestParam(defaultValue = "0") offset: Int,
|
||||
@Parameter(description = "Search term for member names")
|
||||
@RequestParam(required = false) search: String?
|
||||
): ResponseEntity<ApiResponse<List<*>>> {
|
||||
return try {
|
||||
@@ -59,26 +153,31 @@ class MemberController(
|
||||
/**
|
||||
* Get member by ID
|
||||
*/
|
||||
@Operation(
|
||||
summary = "Get member by ID",
|
||||
description = "Retrieve a specific member by their unique identifier"
|
||||
)
|
||||
@ApiResponses(
|
||||
value = [
|
||||
SwaggerApiResponse(responseCode = "200", description = "Member found successfully"),
|
||||
SwaggerApiResponse(responseCode = "400", description = "Invalid member ID format"),
|
||||
SwaggerApiResponse(responseCode = "404", description = "Member not found"),
|
||||
SwaggerApiResponse(responseCode = "500", description = "Internal server error")
|
||||
]
|
||||
)
|
||||
@GetMapping("/{id}")
|
||||
fun getMemberById(@PathVariable id: String): ResponseEntity<ApiResponse<*>> {
|
||||
return try {
|
||||
val memberId = uuidFrom(id)
|
||||
val request = GetMemberUseCase.GetMemberRequest(memberId)
|
||||
val response = runBlocking { getMemberUseCase.execute(request) }
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
ResponseEntity.ok(ApiResponse.success((response.data as GetMemberUseCase.GetMemberResponse).member))
|
||||
} else {
|
||||
ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error<Any>("Member not found"))
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error<Any>("Invalid member ID format"))
|
||||
} catch (e: Exception) {
|
||||
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error<Any>("Failed to retrieve member: ${e.message}"))
|
||||
}
|
||||
fun getMemberById(
|
||||
@Parameter(description = "Member unique identifier", example = "123e4567-e89b-12d3-a456-426614174000")
|
||||
@PathVariable id: String
|
||||
): ResponseEntity<ApiResponse<*>> {
|
||||
return handleUseCaseExecution(
|
||||
operation = {
|
||||
val memberId = uuidFrom(id)
|
||||
val request = GetMemberUseCase.GetMemberRequest(memberId)
|
||||
getMemberUseCase.execute(request)
|
||||
},
|
||||
extractData = { (it as GetMemberUseCase.GetMemberResponse).member }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,36 +244,42 @@ class MemberController(
|
||||
/**
|
||||
* Create new member
|
||||
*/
|
||||
@Operation(
|
||||
summary = "Create new member",
|
||||
description = "Create a new member with the provided information"
|
||||
)
|
||||
@ApiResponses(
|
||||
value = [
|
||||
SwaggerApiResponse(responseCode = "201", description = "Member created successfully"),
|
||||
SwaggerApiResponse(responseCode = "400", description = "Invalid request data"),
|
||||
SwaggerApiResponse(responseCode = "500", description = "Internal server error")
|
||||
]
|
||||
)
|
||||
@PostMapping
|
||||
fun createMember(@RequestBody createRequest: CreateMemberRequest): ResponseEntity<ApiResponse<*>> {
|
||||
return try {
|
||||
val useCaseRequest = CreateMemberUseCase.CreateMemberRequest(
|
||||
firstName = createRequest.firstName,
|
||||
lastName = createRequest.lastName,
|
||||
email = createRequest.email,
|
||||
phone = createRequest.phone,
|
||||
dateOfBirth = createRequest.dateOfBirth,
|
||||
membershipNumber = createRequest.membershipNumber,
|
||||
membershipStartDate = createRequest.membershipStartDate,
|
||||
membershipEndDate = createRequest.membershipEndDate,
|
||||
isActive = createRequest.isActive,
|
||||
address = createRequest.address,
|
||||
emergencyContact = createRequest.emergencyContact
|
||||
)
|
||||
|
||||
val response = runBlocking { createMemberUseCase.execute(useCaseRequest) }
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.success((response.data as CreateMemberUseCase.CreateMemberResponse).member))
|
||||
} else {
|
||||
ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error<Any>(response.error?.message ?: "Failed to create member"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error<Any>("Failed to create member: ${e.message}"))
|
||||
}
|
||||
fun createMember(
|
||||
@Parameter(description = "Member creation request data")
|
||||
@RequestBody createRequest: CreateMemberRequest
|
||||
): ResponseEntity<ApiResponse<*>> {
|
||||
return handleUseCaseExecution(
|
||||
operation = {
|
||||
val useCaseRequest = CreateMemberUseCase.CreateMemberRequest(
|
||||
firstName = createRequest.firstName,
|
||||
lastName = createRequest.lastName,
|
||||
email = createRequest.email,
|
||||
phone = createRequest.phone,
|
||||
dateOfBirth = createRequest.dateOfBirth,
|
||||
membershipNumber = createRequest.membershipNumber,
|
||||
membershipStartDate = createRequest.membershipStartDate,
|
||||
membershipEndDate = createRequest.membershipEndDate,
|
||||
isActive = createRequest.isActive,
|
||||
address = createRequest.address,
|
||||
emergencyContact = createRequest.emergencyContact
|
||||
)
|
||||
createMemberUseCase.execute(useCaseRequest)
|
||||
},
|
||||
successStatus = HttpStatus.CREATED,
|
||||
extractData = { (it as CreateMemberUseCase.CreateMemberResponse).member }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,6 +325,121 @@ class MemberController(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get members with expiring memberships
|
||||
*/
|
||||
@GetMapping("/expiring-memberships")
|
||||
fun getExpiringMemberships(
|
||||
@RequestParam(defaultValue = "30") daysAhead: Int
|
||||
): ResponseEntity<ApiResponse<*>> {
|
||||
return try {
|
||||
val request = FindExpiringMembershipsUseCase.FindExpiringMembershipsRequest(daysAhead)
|
||||
val response = runBlocking { findExpiringMembershipsUseCase.execute(request) }
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
ResponseEntity.ok(ApiResponse.success(response.data))
|
||||
} else {
|
||||
ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error<Any>(response.error?.message ?: "Failed to find expiring memberships"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error<Any>("Failed to find expiring memberships: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get members by date range
|
||||
*/
|
||||
@GetMapping("/by-date-range")
|
||||
fun getMembersByDateRange(
|
||||
@RequestParam startDate: String,
|
||||
@RequestParam endDate: String,
|
||||
@RequestParam(defaultValue = "MEMBERSHIP_START_DATE") dateType: String
|
||||
): ResponseEntity<ApiResponse<*>> {
|
||||
return try {
|
||||
val startLocalDate = LocalDate.parse(startDate)
|
||||
val endLocalDate = LocalDate.parse(endDate)
|
||||
val dateRangeType = FindMembersByDateRangeUseCase.DateRangeType.valueOf(dateType)
|
||||
|
||||
val request = FindMembersByDateRangeUseCase.FindMembersByDateRangeRequest(
|
||||
startDate = startLocalDate,
|
||||
endDate = endLocalDate,
|
||||
dateType = dateRangeType
|
||||
)
|
||||
val response = runBlocking { findMembersByDateRangeUseCase.execute(request) }
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
ResponseEntity.ok(ApiResponse.success(response.data))
|
||||
} else {
|
||||
ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error<Any>(response.error?.message ?: "Failed to find members by date range"))
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error<Any>("Invalid date format or date type. Use YYYY-MM-DD format and MEMBERSHIP_START_DATE or MEMBERSHIP_END_DATE"))
|
||||
} catch (e: Exception) {
|
||||
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error<Any>("Failed to find members by date range: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email uniqueness
|
||||
*/
|
||||
@GetMapping("/validate/email/{email}")
|
||||
fun validateEmail(
|
||||
@PathVariable email: String,
|
||||
@RequestParam(required = false) excludeMemberId: String?
|
||||
): ResponseEntity<ApiResponse<*>> {
|
||||
return try {
|
||||
val excludeId = excludeMemberId?.let { uuidFrom(it) }
|
||||
val request = ValidateMemberDataUseCase.ValidateEmailRequest(email, excludeId)
|
||||
val response = runBlocking { validateMemberDataUseCase.validateEmail(request) }
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
ResponseEntity.ok(ApiResponse.success(response.data))
|
||||
} else {
|
||||
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error<Any>(response.error?.message ?: "Failed to validate email"))
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error<Any>("Invalid member ID format"))
|
||||
} catch (e: Exception) {
|
||||
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error<Any>("Failed to validate email: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate membership number uniqueness
|
||||
*/
|
||||
@GetMapping("/validate/membership-number/{membershipNumber}")
|
||||
fun validateMembershipNumber(
|
||||
@PathVariable membershipNumber: String,
|
||||
@RequestParam(required = false) excludeMemberId: String?
|
||||
): ResponseEntity<ApiResponse<*>> {
|
||||
return try {
|
||||
val excludeId = excludeMemberId?.let { uuidFrom(it) }
|
||||
val request = ValidateMemberDataUseCase.ValidateMembershipNumberRequest(membershipNumber, excludeId)
|
||||
val response = runBlocking { validateMemberDataUseCase.validateMembershipNumber(request) }
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
ResponseEntity.ok(ApiResponse.success(response.data))
|
||||
} else {
|
||||
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error<Any>(response.error?.message ?: "Failed to validate membership number"))
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error<Any>("Invalid member ID format"))
|
||||
} catch (e: Exception) {
|
||||
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error<Any>("Failed to validate membership number: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete member
|
||||
*/
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package at.mocode.members.application.usecase
|
||||
|
||||
import at.mocode.core.domain.model.ApiResponse
|
||||
import at.mocode.core.domain.model.ErrorDto
|
||||
import at.mocode.members.domain.model.Member
|
||||
import at.mocode.members.domain.repository.MemberRepository
|
||||
|
||||
/**
|
||||
* Use case for finding members with expiring memberships.
|
||||
*
|
||||
* This use case handles the business logic for finding members
|
||||
* whose memberships are expiring within a specified number of days.
|
||||
*/
|
||||
class FindExpiringMembershipsUseCase(
|
||||
private val memberRepository: MemberRepository
|
||||
) {
|
||||
|
||||
/**
|
||||
* Request data for finding expiring memberships.
|
||||
*/
|
||||
data class FindExpiringMembershipsRequest(
|
||||
val daysAhead: Int = 30
|
||||
)
|
||||
|
||||
/**
|
||||
* Response data containing the list of members with expiring memberships.
|
||||
*/
|
||||
data class FindExpiringMembershipsResponse(
|
||||
val members: List<Member>,
|
||||
val count: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Executes the find expiring memberships use case.
|
||||
*
|
||||
* @param request The request containing the number of days to look ahead
|
||||
* @return ApiResponse with the list of members or error information
|
||||
*/
|
||||
suspend fun execute(request: FindExpiringMembershipsRequest): ApiResponse<FindExpiringMembershipsResponse> {
|
||||
return try {
|
||||
// Validate input
|
||||
if (request.daysAhead < 0) {
|
||||
return ApiResponse(
|
||||
success = false,
|
||||
error = ErrorDto(
|
||||
code = "INVALID_DAYS_AHEAD",
|
||||
message = "Days ahead must be a positive number"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val members = memberRepository.findMembersWithExpiringMembership(request.daysAhead)
|
||||
|
||||
ApiResponse(
|
||||
success = true,
|
||||
data = FindExpiringMembershipsResponse(
|
||||
members = members,
|
||||
count = members.size
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
ApiResponse(
|
||||
success = false,
|
||||
error = ErrorDto(
|
||||
code = "INTERNAL_ERROR",
|
||||
message = "Failed to find expiring memberships: ${e.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package at.mocode.members.application.usecase
|
||||
|
||||
import at.mocode.core.domain.model.ApiResponse
|
||||
import at.mocode.core.domain.model.ErrorDto
|
||||
import at.mocode.members.domain.model.Member
|
||||
import at.mocode.members.domain.repository.MemberRepository
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* Use case for finding members by date ranges.
|
||||
*
|
||||
* This use case handles the business logic for finding members
|
||||
* based on their membership start or end date ranges.
|
||||
*/
|
||||
class FindMembersByDateRangeUseCase(
|
||||
private val memberRepository: MemberRepository
|
||||
) {
|
||||
|
||||
/**
|
||||
* Request data for finding members by date range.
|
||||
*/
|
||||
data class FindMembersByDateRangeRequest(
|
||||
val startDate: LocalDate,
|
||||
val endDate: LocalDate,
|
||||
val dateType: DateRangeType
|
||||
)
|
||||
|
||||
/**
|
||||
* Type of date range to search by.
|
||||
*/
|
||||
enum class DateRangeType {
|
||||
MEMBERSHIP_START_DATE,
|
||||
MEMBERSHIP_END_DATE
|
||||
}
|
||||
|
||||
/**
|
||||
* Response data containing the list of members within the date range.
|
||||
*/
|
||||
data class FindMembersByDateRangeResponse(
|
||||
val members: List<Member>,
|
||||
val count: Int,
|
||||
val dateType: DateRangeType,
|
||||
val startDate: LocalDate,
|
||||
val endDate: LocalDate
|
||||
)
|
||||
|
||||
/**
|
||||
* Executes the find members by date range use case.
|
||||
*
|
||||
* @param request The request containing the date range and type
|
||||
* @return ApiResponse with the list of members or error information
|
||||
*/
|
||||
suspend fun execute(request: FindMembersByDateRangeRequest): ApiResponse<FindMembersByDateRangeResponse> {
|
||||
return try {
|
||||
// Validate input
|
||||
if (request.startDate > request.endDate) {
|
||||
return ApiResponse(
|
||||
success = false,
|
||||
error = ErrorDto(
|
||||
code = "INVALID_DATE_RANGE",
|
||||
message = "Start date cannot be after end date"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val members = when (request.dateType) {
|
||||
DateRangeType.MEMBERSHIP_START_DATE ->
|
||||
memberRepository.findByMembershipStartDateRange(request.startDate, request.endDate)
|
||||
DateRangeType.MEMBERSHIP_END_DATE ->
|
||||
memberRepository.findByMembershipEndDateRange(request.startDate, request.endDate)
|
||||
}
|
||||
|
||||
ApiResponse(
|
||||
success = true,
|
||||
data = FindMembersByDateRangeResponse(
|
||||
members = members,
|
||||
count = members.size,
|
||||
dateType = request.dateType,
|
||||
startDate = request.startDate,
|
||||
endDate = request.endDate
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
ApiResponse(
|
||||
success = false,
|
||||
error = ErrorDto(
|
||||
code = "INTERNAL_ERROR",
|
||||
message = "Failed to find members by date range: ${e.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package at.mocode.members.application.usecase
|
||||
|
||||
import at.mocode.core.domain.model.ApiResponse
|
||||
import at.mocode.core.domain.model.ErrorDto
|
||||
import at.mocode.members.domain.repository.MemberRepository
|
||||
import com.benasher44.uuid.Uuid
|
||||
|
||||
/**
|
||||
* Use case for validating member data.
|
||||
*
|
||||
* This use case handles the business logic for validating
|
||||
* member data such as email and membership number uniqueness.
|
||||
*/
|
||||
class ValidateMemberDataUseCase(
|
||||
private val memberRepository: MemberRepository
|
||||
) {
|
||||
|
||||
/**
|
||||
* Request data for validating email uniqueness.
|
||||
*/
|
||||
data class ValidateEmailRequest(
|
||||
val email: String,
|
||||
val excludeMemberId: Uuid? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Request data for validating membership number uniqueness.
|
||||
*/
|
||||
data class ValidateMembershipNumberRequest(
|
||||
val membershipNumber: String,
|
||||
val excludeMemberId: Uuid? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Response data for validation results.
|
||||
*/
|
||||
data class ValidationResponse(
|
||||
val isValid: Boolean,
|
||||
val exists: Boolean,
|
||||
val message: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Validates if an email address is unique.
|
||||
*
|
||||
* @param request The request containing email and optional member ID to exclude
|
||||
* @return ApiResponse with validation result
|
||||
*/
|
||||
suspend fun validateEmail(request: ValidateEmailRequest): ApiResponse<ValidationResponse> {
|
||||
return try {
|
||||
// Basic email format validation
|
||||
if (request.email.isBlank()) {
|
||||
return ApiResponse(
|
||||
success = true,
|
||||
data = ValidationResponse(
|
||||
isValid = false,
|
||||
exists = false,
|
||||
message = "Email is required"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (!isValidEmailFormat(request.email)) {
|
||||
return ApiResponse(
|
||||
success = true,
|
||||
data = ValidationResponse(
|
||||
isValid = false,
|
||||
exists = false,
|
||||
message = "Email format is invalid"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val exists = memberRepository.existsByEmail(request.email, request.excludeMemberId)
|
||||
|
||||
ApiResponse(
|
||||
success = true,
|
||||
data = ValidationResponse(
|
||||
isValid = !exists,
|
||||
exists = exists,
|
||||
message = if (exists) "Email already exists" else "Email is available"
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
ApiResponse(
|
||||
success = false,
|
||||
error = ErrorDto(
|
||||
code = "INTERNAL_ERROR",
|
||||
message = "Failed to validate email: ${e.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a membership number is unique.
|
||||
*
|
||||
* @param request The request containing membership number and optional member ID to exclude
|
||||
* @return ApiResponse with validation result
|
||||
*/
|
||||
suspend fun validateMembershipNumber(request: ValidateMembershipNumberRequest): ApiResponse<ValidationResponse> {
|
||||
return try {
|
||||
// Basic membership number validation
|
||||
if (request.membershipNumber.isBlank()) {
|
||||
return ApiResponse(
|
||||
success = true,
|
||||
data = ValidationResponse(
|
||||
isValid = false,
|
||||
exists = false,
|
||||
message = "Membership number is required"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val exists = memberRepository.existsByMembershipNumber(request.membershipNumber, request.excludeMemberId)
|
||||
|
||||
ApiResponse(
|
||||
success = true,
|
||||
data = ValidationResponse(
|
||||
isValid = !exists,
|
||||
exists = exists,
|
||||
message = if (exists) "Membership number already exists" else "Membership number is available"
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
ApiResponse(
|
||||
success = false,
|
||||
error = ErrorDto(
|
||||
code = "INTERNAL_ERROR",
|
||||
message = "Failed to validate membership number: ${e.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic email format validation.
|
||||
*/
|
||||
private fun isValidEmailFormat(email: String): Boolean {
|
||||
return email.contains("@") &&
|
||||
email.contains(".") &&
|
||||
email.indexOf("@") > 0 &&
|
||||
email.lastIndexOf(".") > email.indexOf("@") &&
|
||||
email.length > 5
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import com.benasher44.uuid.uuid4
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
@@ -77,8 +79,12 @@ data class Member(
|
||||
* Checks if the membership is currently valid.
|
||||
*/
|
||||
fun isMembershipValid(): Boolean {
|
||||
// Simplified implementation - can be enhanced with proper date comparison
|
||||
return isActive && membershipEndDate != null
|
||||
if (!isActive) return false
|
||||
|
||||
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
return membershipEndDate?.let { endDate ->
|
||||
today <= endDate
|
||||
} ?: true // If no end date, membership is valid indefinitely
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ import kotlin.test.assertTrue
|
||||
/**
|
||||
* Integration tests for the Members Service.
|
||||
*
|
||||
* These tests verify the complete functionality including:
|
||||
* These tests verify the complete functionality including
|
||||
* - REST API endpoints
|
||||
* - Database operations
|
||||
* - Event publishing
|
||||
|
||||
Reference in New Issue
Block a user