fixing web-app

This commit is contained in:
stefan
2025-09-24 14:21:57 +02:00
parent cd2b0796a6
commit 1c4184809a
156 changed files with 440 additions and 1708 deletions
-333
View File
@@ -1,333 +0,0 @@
# 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).
-29
View File
@@ -1,29 +0,0 @@
plugins {
// kotlin("jvm")
// kotlin("plugin.spring")
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.spring)
// KORREKTUR: Dieses Plugin ist entscheidend. Es schaltet den `springBoot`-Block
// und alle Spring-Boot-spezifischen Gradle-Tasks frei.
alias(libs.plugins.spring.boot)
// Dependency Management für konsistente Spring-Versionen
alias(libs.plugins.spring.dependencyManagement)
}
dependencies {
implementation(projects.platform.platformDependencies)
implementation(projects.members.membersDomain)
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")
testImplementation(projects.platform.platformTesting)
}
@@ -1,495 +0,0 @@
package at.mocode.members.api.rest
import at.mocode.core.domain.model.ApiResponse
import at.mocode.infrastructure.messaging.client.EventPublisher
import at.mocode.members.application.usecase.*
import at.mocode.members.domain.repository.MemberRepository
import com.benasher44.uuid.uuidFrom
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.responses.ApiResponses
import io.swagger.v3.oas.annotations.tags.Tag
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.responses.ApiResponse as SwaggerApiResponse
/**
* Einfache No-op EventPublisher Implementierung für den Controller.
*/
class NoOpEventPublisher : EventPublisher {
override suspend fun publishEvent(topic: String, key: String?, event: Any) {
// No-op Implementierung - Events werden in dieser einfachen Version nicht veröffentlicht
}
override suspend fun publishEvents(topic: String, events: List<Pair<String?, Any>>) {
// No-op Implementierung - Events werden in dieser einfachen Version nicht veröffentlicht
}
}
/**
* REST API Controller für Mitgliederverwaltungs-Operationen.
*
* Dieser Controller stellt HTTP-Endpunkte für alle mitgliederbezogenen Operationen
* zur Verfügung, einschließlich CRUD-Operationen und Mitgliedersuche.
*/
@RestController
@RequestMapping("/api/members")
@Tag(name = "Members", description = "Mitgliederverwaltungs-Operationen")
class MemberController(
@Qualifier("memberRepositoryImpl") private val memberRepository: MemberRepository
) {
// Einfache No-op EventPublisher Implementierung vorerst
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)
/**
* Hilfsmethode zur Behandlung gemeinsamer Antwortmuster für Use-Case-Ausführung
*/
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}"))
}
}
/**
* Hilfsmethode zur Behandlung von Repository-Operationen mit gemeinsamer Fehlerbehandlung
*/
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}"))
}
}
/**
* Alle Mitglieder mit optionaler Filterung abrufen
*/
@Operation(
summary = "Alle Mitglieder abrufen",
description = "Abrufen aller Mitglieder mit optionaler Filterung nach Aktivitätsstatus und Suchbegriff"
)
@ApiResponses(
value = [
SwaggerApiResponse(responseCode = "200", description = "Mitglieder erfolgreich abgerufen"),
SwaggerApiResponse(responseCode = "500", description = "Interner Serverfehler")
]
)
@GetMapping
fun getAllMembers(
@Parameter(description = "Nur nach aktiven Mitgliedern filtern", example = "true")
@RequestParam(defaultValue = "true") activeOnly: Boolean,
@Parameter(description = "Maximale Anzahl der zurückzugebenden Ergebnisse", example = "100")
@RequestParam(defaultValue = "100") limit: Int,
@Parameter(description = "Anzahl der zu überspringenden Ergebnisse", example = "0")
@RequestParam(defaultValue = "0") offset: Int,
@Parameter(description = "Suchbegriff für Mitgliedernamen")
@RequestParam(required = false) search: String?
): ResponseEntity<ApiResponse<List<*>>> {
return try {
val members = runBlocking {
when {
search != null -> memberRepository.findByName(search, limit)
activeOnly -> memberRepository.findAllActive(limit, offset)
else -> memberRepository.findAll(limit, offset)
}
}
ResponseEntity.ok(ApiResponse.success(members))
} catch (e: Exception) {
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error<List<*>>("Failed to retrieve members: ${e.message}"))
}
}
/**
* 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(
@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 }
)
}
/**
* Get member by membership number
*/
@GetMapping("/by-membership-number/{membershipNumber}")
fun getMemberByMembershipNumber(@PathVariable membershipNumber: String): ResponseEntity<ApiResponse<*>> {
return try {
val response = runBlocking { getMemberUseCase.getByMembershipNumber(membershipNumber) }
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 (e: Exception) {
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error<Any>("Failed to retrieve member: ${e.message}"))
}
}
/**
* Get member by email
*/
@GetMapping("/by-email/{email}")
fun getMemberByEmail(@PathVariable email: String): ResponseEntity<ApiResponse<*>> {
return try {
val response = runBlocking { getMemberUseCase.getByEmail(email) }
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 (e: Exception) {
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error<Any>("Failed to retrieve member: ${e.message}"))
}
}
/**
* Get member statistics
*/
@GetMapping("/stats")
fun getMemberStats(): ResponseEntity<ApiResponse<MemberStats>> {
return try {
val activeCount = runBlocking { memberRepository.countActive() }
val totalCount = runBlocking { memberRepository.countAll() }
val stats = MemberStats(
totalActive = activeCount,
totalMembers = totalCount
)
ResponseEntity.ok(ApiResponse.success(stats))
} catch (e: Exception) {
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error<MemberStats>("Failed to retrieve member statistics: ${e.message}"))
}
}
/**
* 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(
@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 }
)
}
/**
* Update member
*/
@PutMapping("/{id}")
fun updateMember(@PathVariable id: String, @RequestBody updateRequest: UpdateMemberRequest): ResponseEntity<ApiResponse<*>> {
return try {
val memberId = uuidFrom(id)
val useCaseRequest = UpdateMemberUseCase.UpdateMemberRequest(
memberId = memberId,
firstName = updateRequest.firstName,
lastName = updateRequest.lastName,
email = updateRequest.email,
phone = updateRequest.phone,
dateOfBirth = updateRequest.dateOfBirth,
membershipNumber = updateRequest.membershipNumber,
membershipStartDate = updateRequest.membershipStartDate,
membershipEndDate = updateRequest.membershipEndDate,
isActive = updateRequest.isActive,
address = updateRequest.address,
emergencyContact = updateRequest.emergencyContact
)
val response = runBlocking { updateMemberUseCase.execute(useCaseRequest) }
if (response.success && response.data != null) {
ResponseEntity.ok(ApiResponse.success((response.data as UpdateMemberUseCase.UpdateMemberResponse).member))
} else {
val statusCode = when (response.error?.code) {
"MEMBER_NOT_FOUND" -> HttpStatus.NOT_FOUND
else -> HttpStatus.BAD_REQUEST
}
ResponseEntity.status(statusCode)
.body(ApiResponse.error<Any>(response.error?.message ?: "Failed to update member"))
}
} 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 update member: ${e.message}"))
}
}
/**
* 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
*/
@DeleteMapping("/{id}")
fun deleteMember(@PathVariable id: String): ResponseEntity<ApiResponse<String>> {
return try {
val memberId = uuidFrom(id)
val request = DeleteMemberUseCase.DeleteMemberRequest(memberId)
val response = runBlocking { deleteMemberUseCase.execute(request) }
if (response.success) {
ResponseEntity.ok(ApiResponse.success("Member deleted successfully"))
} else {
val statusCode = when (response.error?.code) {
"MEMBER_NOT_FOUND" -> HttpStatus.NOT_FOUND
else -> HttpStatus.BAD_REQUEST
}
ResponseEntity.status(statusCode)
.body(ApiResponse.error<String>(response.error?.message ?: "Failed to delete member"))
}
} catch (_: IllegalArgumentException) {
ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error<String>("Invalid member ID format"))
} catch (e: Exception) {
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error<String>("Failed to delete member: ${e.message}"))
}
}
data class CreateMemberRequest(
val firstName: String,
val lastName: String,
val email: String,
val phone: String? = null,
val dateOfBirth: LocalDate? = null,
val membershipNumber: String,
val membershipStartDate: LocalDate,
val membershipEndDate: LocalDate? = null,
val isActive: Boolean = true,
val address: String? = null,
val emergencyContact: String? = null
)
data class UpdateMemberRequest(
val firstName: String,
val lastName: String,
val email: String,
val phone: String? = null,
val dateOfBirth: LocalDate? = null,
val membershipNumber: String,
val membershipStartDate: LocalDate,
val membershipEndDate: LocalDate? = null,
val isActive: Boolean = true,
val address: String? = null,
val emergencyContact: String? = null
)
data class MemberStats(
val totalActive: Long,
val totalMembers: Long
)
}
@@ -1,11 +0,0 @@
plugins {
kotlin("jvm")
}
dependencies {
implementation(projects.members.membersDomain)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.infrastructure.messaging.messagingClient)
testImplementation(projects.platform.platformTesting)
}
@@ -1,239 +0,0 @@
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 at.mocode.members.domain.events.MemberCreatedEvent
import at.mocode.infrastructure.messaging.client.EventPublisher
import at.mocode.core.domain.model.ValidationResult
import at.mocode.core.domain.model.ValidationError
import com.benasher44.uuid.uuid4
import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDate
/**
* Use case for creating new members.
*
* This use case handles the business logic for creating members,
* including validation and persistence.
*/
class CreateMemberUseCase(
private val memberRepository: MemberRepository,
private val eventPublisher: EventPublisher
) {
/**
* Request data for creating a new member.
*/
data class CreateMemberRequest(
val firstName: String,
val lastName: String,
val email: String,
val phone: String? = null,
val dateOfBirth: LocalDate? = null,
val membershipNumber: String,
val membershipStartDate: LocalDate,
val membershipEndDate: LocalDate? = null,
val isActive: Boolean = true,
val address: String? = null,
val emergencyContact: String? = null
)
/**
* Response data containing the created member.
*/
data class CreateMemberResponse(
val member: Member
)
/**
* Executes the create member use case.
*
* @param request The request containing member data
* @return ApiResponse with the created member or error information
*/
suspend fun execute(request: CreateMemberRequest): ApiResponse<CreateMemberResponse> {
return try {
// Validate the request
val validationResult = validateRequest(request)
if (!validationResult.isValid()) {
val errors = (validationResult as ValidationResult.Invalid).errors
return ApiResponse(
success = false,
error = ErrorDto(
code = "VALIDATION_ERROR",
message = "Invalid input data",
details = errors.associate { it.field to it.message }
)
)
}
// Check for duplicate membership number
if (memberRepository.existsByMembershipNumber(request.membershipNumber)) {
return ApiResponse(
success = false,
error = ErrorDto(
code = "DUPLICATE_MEMBERSHIP_NUMBER",
message = "Membership number already exists"
)
)
}
// Check for duplicate email
if (memberRepository.existsByEmail(request.email)) {
return ApiResponse(
success = false,
error = ErrorDto(
code = "DUPLICATE_EMAIL",
message = "Email address already exists"
)
)
}
// Create the domain object
val member = Member(
firstName = request.firstName.trim(),
lastName = request.lastName.trim(),
email = request.email.trim().lowercase(),
phone = request.phone?.trim(),
dateOfBirth = request.dateOfBirth,
membershipNumber = request.membershipNumber.trim(),
membershipStartDate = request.membershipStartDate,
membershipEndDate = request.membershipEndDate,
isActive = request.isActive,
address = request.address?.trim(),
emergencyContact = request.emergencyContact?.trim(),
createdAt = Clock.System.now(),
updatedAt = Clock.System.now()
)
// Validate the domain object
val domainValidationErrors = member.validate()
if (domainValidationErrors.isNotEmpty()) {
return ApiResponse(
success = false,
error = ErrorDto(
code = "DOMAIN_VALIDATION_ERROR",
message = "Domain validation failed",
details = domainValidationErrors.mapIndexed { index, error ->
"error_$index" to error
}.toMap()
)
)
}
// Save the member
val savedMember = memberRepository.save(member)
// Publish member created event
try {
val event = MemberCreatedEvent(
eventId = uuid4().toString(),
memberId = savedMember.memberId,
timestamp = Clock.System.now(),
firstName = savedMember.firstName,
lastName = savedMember.lastName,
email = savedMember.email,
membershipNumber = savedMember.membershipNumber,
membershipStartDate = savedMember.membershipStartDate,
isActive = savedMember.isActive
)
eventPublisher.publishEvent("member-events", savedMember.memberId.toString(), event)
} catch (e: Exception) {
// Log the error but don't fail the operation
// In a production system, you might want to use a dead letter queue or retry mechanism
println("Failed to publish member created event: ${e.message}")
}
ApiResponse(
success = true,
data = CreateMemberResponse(savedMember)
)
} catch (e: Exception) {
ApiResponse(
success = false,
error = ErrorDto(
code = "INTERNAL_ERROR",
message = "Failed to create member: ${e.message}"
)
)
}
}
/**
* Validates the create member request.
*/
private fun validateRequest(request: CreateMemberRequest): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Validate first name
if (request.firstName.isBlank()) {
errors.add(ValidationError("firstName", "First name is required"))
} else if (request.firstName.length > 100) {
errors.add(ValidationError("firstName", "First name must not exceed 100 characters"))
}
// Validate last name
if (request.lastName.isBlank()) {
errors.add(ValidationError("lastName", "Last name is required"))
} else if (request.lastName.length > 100) {
errors.add(ValidationError("lastName", "Last name must not exceed 100 characters"))
}
// Validate email
if (request.email.isBlank()) {
errors.add(ValidationError("email", "Email is required"))
} else if (!isValidEmail(request.email)) {
errors.add(ValidationError("email", "Email format is invalid"))
} else if (request.email.length > 255) {
errors.add(ValidationError("email", "Email must not exceed 255 characters"))
}
// Validate membership number
if (request.membershipNumber.isBlank()) {
errors.add(ValidationError("membershipNumber", "Membership number is required"))
} else if (request.membershipNumber.length > 50) {
errors.add(ValidationError("membershipNumber", "Membership number must not exceed 50 characters"))
}
// Validate membership dates
request.membershipEndDate?.let { endDate ->
if (endDate < request.membershipStartDate) {
errors.add(ValidationError("membershipEndDate", "Membership end date cannot be before start date"))
}
}
// Validate phone
request.phone?.let { phone ->
if (phone.length > 50) {
errors.add(ValidationError("phone", "Phone number must not exceed 50 characters"))
}
}
// Validate address
request.address?.let { address ->
if (address.length > 500) {
errors.add(ValidationError("address", "Address must not exceed 500 characters"))
}
}
// Validate emergency contact
request.emergencyContact?.let { contact ->
if (contact.length > 255) {
errors.add(ValidationError("emergencyContact", "Emergency contact must not exceed 255 characters"))
}
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
private fun isValidEmail(email: String): Boolean {
return email.contains("@") && email.contains(".") && email.indexOf("@") < email.lastIndexOf(".")
}
}
@@ -1,84 +0,0 @@
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 deleting members.
*
* This use case handles the business logic for deleting members
* from the system.
*/
class DeleteMemberUseCase(
private val memberRepository: MemberRepository
) {
/**
* Request data for deleting a member.
*/
data class DeleteMemberRequest(
val memberId: Uuid
)
/**
* Response data for delete operation.
*/
data class DeleteMemberResponse(
val success: Boolean,
val message: String
)
/**
* Executes the delete member use case.
*
* @param request The request containing member ID to delete
* @return ApiResponse with the result or error information
*/
suspend fun execute(request: DeleteMemberRequest): ApiResponse<DeleteMemberResponse> {
return try {
// Check if member exists
val existingMember = memberRepository.findById(request.memberId)
if (existingMember == null) {
return ApiResponse(
success = false,
error = ErrorDto(
code = "MEMBER_NOT_FOUND",
message = "Member not found"
)
)
}
// Delete the member
val deleted = memberRepository.delete(request.memberId)
if (deleted) {
ApiResponse(
success = true,
data = DeleteMemberResponse(
success = true,
message = "Member deleted successfully"
)
)
} else {
ApiResponse(
success = false,
error = ErrorDto(
code = "DELETE_FAILED",
message = "Failed to delete member"
)
)
}
} catch (e: Exception) {
ApiResponse(
success = false,
error = ErrorDto(
code = "INTERNAL_ERROR",
message = "Failed to delete member: ${e.message}"
)
)
}
}
}
@@ -1,71 +0,0 @@
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}"
)
)
}
}
}
@@ -1,93 +0,0 @@
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}"
)
)
}
}
}
@@ -1,131 +0,0 @@
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 com.benasher44.uuid.Uuid
/**
* Use case for retrieving members.
*
* This use case handles the business logic for retrieving members
* by various criteria.
*/
class GetMemberUseCase(
private val memberRepository: MemberRepository
) {
/**
* Request data for getting a member by ID.
*/
data class GetMemberRequest(
val memberId: Uuid
)
/**
* Response data containing the retrieved member.
*/
data class GetMemberResponse(
val member: Member
)
/**
* Executes the get member use case.
*
* @param request The request containing member ID
* @return ApiResponse with the member or error information
*/
suspend fun execute(request: GetMemberRequest): ApiResponse<GetMemberResponse> {
return try {
val member = memberRepository.findById(request.memberId)
if (member != null) {
ApiResponse(
success = true,
data = GetMemberResponse(member)
)
} else {
ApiResponse(
success = false,
error = ErrorDto(
code = "MEMBER_NOT_FOUND",
message = "Member not found"
)
)
}
} catch (e: Exception) {
ApiResponse(
success = false,
error = ErrorDto(
code = "INTERNAL_ERROR",
message = "Failed to retrieve member: ${e.message}"
)
)
}
}
/**
* Gets a member by membership number.
*/
suspend fun getByMembershipNumber(membershipNumber: String): ApiResponse<GetMemberResponse> {
return try {
val member = memberRepository.findByMembershipNumber(membershipNumber)
if (member != null) {
ApiResponse(
success = true,
data = GetMemberResponse(member)
)
} else {
ApiResponse(
success = false,
error = ErrorDto(
code = "MEMBER_NOT_FOUND",
message = "Member not found"
)
)
}
} catch (e: Exception) {
ApiResponse(
success = false,
error = ErrorDto(
code = "INTERNAL_ERROR",
message = "Failed to retrieve member: ${e.message}"
)
)
}
}
/**
* Gets a member by email address.
*/
suspend fun getByEmail(email: String): ApiResponse<GetMemberResponse> {
return try {
val member = memberRepository.findByEmail(email)
if (member != null) {
ApiResponse(
success = true,
data = GetMemberResponse(member)
)
} else {
ApiResponse(
success = false,
error = ErrorDto(
code = "MEMBER_NOT_FOUND",
message = "Member not found"
)
)
}
} catch (e: Exception) {
ApiResponse(
success = false,
error = ErrorDto(
code = "INTERNAL_ERROR",
message = "Failed to retrieve member: ${e.message}"
)
)
}
}
}
@@ -1,226 +0,0 @@
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 at.mocode.core.domain.model.ValidationResult
import at.mocode.core.domain.model.ValidationError
import com.benasher44.uuid.Uuid
import kotlinx.datetime.LocalDate
/**
* Use case for updating existing members.
*
* This use case handles the business logic for updating members,
* including validation and persistence.
*/
class UpdateMemberUseCase(
private val memberRepository: MemberRepository
) {
/**
* Request data for updating a member.
*/
data class UpdateMemberRequest(
val memberId: Uuid,
val firstName: String,
val lastName: String,
val email: String,
val phone: String? = null,
val dateOfBirth: LocalDate? = null,
val membershipNumber: String,
val membershipStartDate: LocalDate,
val membershipEndDate: LocalDate? = null,
val isActive: Boolean = true,
val address: String? = null,
val emergencyContact: String? = null
)
/**
* Response data containing the updated member.
*/
data class UpdateMemberResponse(
val member: Member
)
/**
* Executes the update member use case.
*
* @param request The request containing updated member data
* @return ApiResponse with the updated member or error information
*/
suspend fun execute(request: UpdateMemberRequest): ApiResponse<UpdateMemberResponse> {
return try {
// Check if member exists
val existingMember = memberRepository.findById(request.memberId)
if (existingMember == null) {
return ApiResponse(
success = false,
error = ErrorDto(
code = "MEMBER_NOT_FOUND",
message = "Member not found"
)
)
}
// Validate the request
val validationResult = validateRequest(request)
if (!validationResult.isValid()) {
val errors = (validationResult as ValidationResult.Invalid).errors
return ApiResponse(
success = false,
error = ErrorDto(
code = "VALIDATION_ERROR",
message = "Invalid input data",
details = errors.associate { it.field to it.message }
)
)
}
// Check for duplicate membership number (excluding current member)
if (memberRepository.existsByMembershipNumber(request.membershipNumber, request.memberId)) {
return ApiResponse(
success = false,
error = ErrorDto(
code = "DUPLICATE_MEMBERSHIP_NUMBER",
message = "Membership number already exists"
)
)
}
// Check for duplicate email (excluding current member)
if (memberRepository.existsByEmail(request.email, request.memberId)) {
return ApiResponse(
success = false,
error = ErrorDto(
code = "DUPLICATE_EMAIL",
message = "Email address already exists"
)
)
}
// Update the member
val updatedMember = existingMember.copy(
firstName = request.firstName.trim(),
lastName = request.lastName.trim(),
email = request.email.trim().lowercase(),
phone = request.phone?.trim(),
dateOfBirth = request.dateOfBirth,
membershipNumber = request.membershipNumber.trim(),
membershipStartDate = request.membershipStartDate,
membershipEndDate = request.membershipEndDate,
isActive = request.isActive,
address = request.address?.trim(),
emergencyContact = request.emergencyContact?.trim()
).withUpdatedTimestamp()
// Validate the domain object
val domainValidationErrors = updatedMember.validate()
if (domainValidationErrors.isNotEmpty()) {
return ApiResponse(
success = false,
error = ErrorDto(
code = "DOMAIN_VALIDATION_ERROR",
message = "Domain validation failed",
details = domainValidationErrors.mapIndexed { index, error ->
"error_$index" to error
}.toMap()
)
)
}
// Save the updated member
val savedMember = memberRepository.save(updatedMember)
ApiResponse(
success = true,
data = UpdateMemberResponse(savedMember)
)
} catch (e: Exception) {
ApiResponse(
success = false,
error = ErrorDto(
code = "INTERNAL_ERROR",
message = "Failed to update member: ${e.message}"
)
)
}
}
/**
* Validates the update member request.
*/
private fun validateRequest(request: UpdateMemberRequest): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Validate first name
if (request.firstName.isBlank()) {
errors.add(ValidationError("firstName", "First name is required"))
} else if (request.firstName.length > 100) {
errors.add(ValidationError("firstName", "First name must not exceed 100 characters"))
}
// Validate last name
if (request.lastName.isBlank()) {
errors.add(ValidationError("lastName", "Last name is required"))
} else if (request.lastName.length > 100) {
errors.add(ValidationError("lastName", "Last name must not exceed 100 characters"))
}
// Validate email
if (request.email.isBlank()) {
errors.add(ValidationError("email", "Email is required"))
} else if (!isValidEmail(request.email)) {
errors.add(ValidationError("email", "Email format is invalid"))
} else if (request.email.length > 255) {
errors.add(ValidationError("email", "Email must not exceed 255 characters"))
}
// Validate membership number
if (request.membershipNumber.isBlank()) {
errors.add(ValidationError("membershipNumber", "Membership number is required"))
} else if (request.membershipNumber.length > 50) {
errors.add(ValidationError("membershipNumber", "Membership number must not exceed 50 characters"))
}
// Validate membership dates
request.membershipEndDate?.let { endDate ->
if (endDate < request.membershipStartDate) {
errors.add(ValidationError("membershipEndDate", "Membership end date cannot be before start date"))
}
}
// Validate phone
request.phone?.let { phone ->
if (phone.length > 50) {
errors.add(ValidationError("phone", "Phone number must not exceed 50 characters"))
}
}
// Validate address
request.address?.let { address ->
if (address.length > 500) {
errors.add(ValidationError("address", "Address must not exceed 500 characters"))
}
}
// Validate emergency contact
request.emergencyContact?.let { contact ->
if (contact.length > 255) {
errors.add(ValidationError("emergencyContact", "Emergency contact must not exceed 255 characters"))
}
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
private fun isValidEmail(email: String): Boolean {
return email.contains("@") && email.contains(".") && email.indexOf("@") < email.lastIndexOf(".")
}
}
@@ -1,146 +0,0 @@
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
}
}
-9
View File
@@ -1,9 +0,0 @@
plugins {
kotlin("jvm")
}
dependencies {
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
testImplementation(projects.platform.platformTesting)
}
@@ -1,82 +0,0 @@
package at.mocode.members.domain.events
import com.benasher44.uuid.Uuid
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
/**
* Base interface for all member domain events.
*/
sealed interface MemberEvent {
val eventId: String
val memberId: Uuid
val timestamp: Instant
val eventType: String
}
/**
* Event published when a new member is created.
*/
data class MemberCreatedEvent(
override val eventId: String,
override val memberId: Uuid,
override val timestamp: Instant,
val firstName: String,
val lastName: String,
val email: String,
val membershipNumber: String,
val membershipStartDate: LocalDate,
val isActive: Boolean
) : MemberEvent {
override val eventType: String = "MemberCreated"
}
/**
* Event published when a member is updated.
*/
data class MemberUpdatedEvent(
override val eventId: String,
override val memberId: Uuid,
override val timestamp: Instant,
val firstName: String,
val lastName: String,
val email: String,
val membershipNumber: String,
val membershipStartDate: LocalDate,
val membershipEndDate: LocalDate?,
val isActive: Boolean,
val changes: Map<String, Any?>
) : MemberEvent {
override val eventType: String = "MemberUpdated"
}
/**
* Event published when a member is deleted.
*/
data class MemberDeletedEvent(
override val eventId: String,
override val memberId: Uuid,
override val timestamp: Instant,
val membershipNumber: String,
val firstName: String,
val lastName: String
) : MemberEvent {
override val eventType: String = "MemberDeleted"
}
/**
* Event published when a member's membership is about to expire.
*/
data class MembershipExpiringEvent(
override val eventId: String,
override val memberId: Uuid,
override val timestamp: Instant,
val membershipNumber: String,
val firstName: String,
val lastName: String,
val email: String,
val membershipEndDate: LocalDate,
val daysUntilExpiry: Int
) : MemberEvent {
override val eventType: String = "MembershipExpiring"
}
@@ -1,133 +0,0 @@
package at.mocode.members.domain.model
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.KotlinLocalDateSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import com.benasher44.uuid.Uuid
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
/**
* Domain model representing a member in the member management system.
*
* This entity represents a member of the organization with their personal
* information and membership details.
*
* @property memberId Unique internal identifier for this member (UUID).
* @property firstName First name of the member.
* @property lastName Last name of the member.
* @property email Email address of the member.
* @property phone Phone number of the member (optional).
* @property dateOfBirth Date of birth of the member (optional).
* @property membershipNumber Unique membership number.
* @property membershipStartDate Date when membership started.
* @property membershipEndDate Date when membership ends (optional).
* @property isActive Whether the membership is currently active.
* @property address Address of the member (optional).
* @property emergencyContact Emergency contact information (optional).
* @property createdAt Timestamp when this record was created.
* @property updatedAt Timestamp when this record was last updated.
*/
@Serializable
data class Member(
@Serializable(with = UuidSerializer::class)
val memberId: Uuid = uuid4(),
// Personal Information
var firstName: String,
var lastName: String,
var email: String,
var phone: String? = null,
@Serializable(with = KotlinLocalDateSerializer::class)
var dateOfBirth: LocalDate? = null,
// Membership Information
var membershipNumber: String,
@Serializable(with = KotlinLocalDateSerializer::class)
var membershipStartDate: LocalDate,
@Serializable(with = KotlinLocalDateSerializer::class)
var membershipEndDate: LocalDate? = null,
var isActive: Boolean = true,
// Additional Information
var address: String? = null,
var emergencyContact: String? = null,
// Audit Fields
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
) {
/**
* Returns the full name of the member.
*/
fun getFullName(): String {
return "$firstName $lastName"
}
/**
* Checks if the membership is currently valid.
*/
fun isMembershipValid(): Boolean {
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
}
/**
* Validates that the member data is consistent.
*/
fun validate(): List<String> {
val errors = mutableListOf<String>()
if (firstName.isBlank()) {
errors.add("First name is required")
}
if (lastName.isBlank()) {
errors.add("Last name is required")
}
if (email.isBlank()) {
errors.add("Email is required")
} else if (!isValidEmail(email)) {
errors.add("Email format is invalid")
}
if (membershipNumber.isBlank()) {
errors.add("Membership number is required")
}
membershipEndDate?.let { endDate ->
if (endDate < membershipStartDate) {
errors.add("Membership end date cannot be before start date")
}
}
return errors
}
/**
* Creates a copy of this member with updated timestamp.
*/
fun withUpdatedTimestamp(): Member {
return this.copy(updatedAt = Clock.System.now())
}
private fun isValidEmail(email: String): Boolean {
return email.contains("@") && email.contains(".")
}
}
@@ -1,139 +0,0 @@
package at.mocode.members.domain.repository
import at.mocode.members.domain.model.Member
import com.benasher44.uuid.Uuid
import kotlinx.datetime.LocalDate
/**
* Repository interface for Member entities.
*
* This interface defines the contract for data access operations
* related to members in the member management bounded context.
*/
interface MemberRepository {
/**
* Finds a member by their unique identifier.
*
* @param id The unique identifier of the member
* @return The member if found, null otherwise
*/
suspend fun findById(id: Uuid): Member?
/**
* Finds a member by their membership number.
*
* @param membershipNumber The membership number to search for
* @return The member if found, null otherwise
*/
suspend fun findByMembershipNumber(membershipNumber: String): Member?
/**
* Finds a member by their email address.
*
* @param email The email address to search for
* @return The member if found, null otherwise
*/
suspend fun findByEmail(email: String): Member?
/**
* Finds members by name (partial match on first or last name).
*
* @param searchTerm The search term to match against member names
* @param limit Maximum number of results to return
* @return List of matching members
*/
suspend fun findByName(searchTerm: String, limit: Int = 50): List<Member>
/**
* Finds all active members.
*
* @param limit Maximum number of results to return
* @param offset Number of results to skip
* @return List of active members
*/
suspend fun findAllActive(limit: Int = 100, offset: Int = 0): List<Member>
/**
* Finds all members (active and inactive).
*
* @param limit Maximum number of results to return
* @param offset Number of results to skip
* @return List of all members
*/
suspend fun findAll(limit: Int = 100, offset: Int = 0): List<Member>
/**
* Finds members whose membership started within a date range.
*
* @param startDate The earliest membership start date to include
* @param endDate The latest membership start date to include
* @return List of members within the specified date range
*/
suspend fun findByMembershipStartDateRange(startDate: LocalDate, endDate: LocalDate): List<Member>
/**
* Finds members whose membership expires within a date range.
*
* @param startDate The earliest membership end date to include
* @param endDate The latest membership end date to include
* @return List of members whose membership expires within the specified date range
*/
suspend fun findByMembershipEndDateRange(startDate: LocalDate, endDate: LocalDate): List<Member>
/**
* Finds members with expiring memberships (within the next specified days).
*
* @param daysAhead Number of days to look ahead for expiring memberships
* @return List of members with expiring memberships
*/
suspend fun findMembersWithExpiringMembership(daysAhead: Int = 30): List<Member>
/**
* Saves a member (insert or update).
*
* @param member The member to save
* @return The saved member
*/
suspend fun save(member: Member): Member
/**
* Deletes a member by their ID.
*
* @param id The unique identifier of the member to delete
* @return True if the member was deleted, false if not found
*/
suspend fun delete(id: Uuid): Boolean
/**
* Counts the number of active members.
*
* @return The number of active members
*/
suspend fun countActive(): Long
/**
* Counts the total number of members.
*
* @return The total number of members
*/
suspend fun countAll(): Long
/**
* Checks if a membership number already exists.
*
* @param membershipNumber The membership number to check
* @param excludeMemberId Optional member ID to exclude from the check (for updates)
* @return True if the membership number exists, false otherwise
*/
suspend fun existsByMembershipNumber(membershipNumber: String, excludeMemberId: Uuid? = null): Boolean
/**
* Checks if an email address already exists.
*
* @param email The email address to check
* @param excludeMemberId Optional member ID to exclude from the check (for updates)
* @return True if the email exists, false otherwise
*/
suspend fun existsByEmail(email: String, excludeMemberId: Uuid? = null): Boolean
}
@@ -1,32 +0,0 @@
plugins {
// kotlin("jvm")
// kotlin("plugin.spring")
// kotlin("plugin.jpa") version "2.1.21"
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.spring)
// KORREKTUR: Dieses Plugin ist entscheidend. Es schaltet den `springBoot`-Block
// und alle Spring-Boot-spezifischen Gradle-Tasks frei.
alias(libs.plugins.spring.boot)
// Dependency Management für konsistente Spring-Versionen
alias(libs.plugins.spring.dependencyManagement)
}
dependencies {
api(platform(projects.platform.platformBom))
implementation(projects.members.membersDomain)
implementation(projects.members.membersApplication)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.infrastructure.cache.cacheApi)
implementation(projects.infrastructure.eventStore.eventStoreApi)
implementation(projects.infrastructure.messaging.messagingClient)
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.postgresql:postgresql")
testImplementation(projects.platform.platformTesting)
}
@@ -1,179 +0,0 @@
package at.mocode.members.infrastructure.persistence
import at.mocode.core.utils.database.DatabaseFactory
import at.mocode.members.domain.model.Member
import at.mocode.members.domain.repository.MemberRepository
import com.benasher44.uuid.Uuid
import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.springframework.stereotype.Repository
/**
* Database implementation of MemberRepository using Exposed ORM.
*/
@Repository
class MemberRepositoryImpl : MemberRepository {
override suspend fun findById(id: Uuid): Member? = DatabaseFactory.dbQuery {
MemberTable.selectAll().where { MemberTable.id eq id }
.map { rowToMember(it) }
.singleOrNull()
}
override suspend fun findByMembershipNumber(membershipNumber: String): Member? = DatabaseFactory.dbQuery {
MemberTable.selectAll().where { MemberTable.membershipNumber eq membershipNumber }
.map { rowToMember(it) }
.singleOrNull()
}
override suspend fun findByEmail(email: String): Member? = DatabaseFactory.dbQuery {
MemberTable.selectAll().where { MemberTable.email.lowerCase() eq email.lowercase() }
.map { rowToMember(it) }
.singleOrNull()
}
override suspend fun findByName(searchTerm: String, limit: Int): List<Member> = DatabaseFactory.dbQuery {
MemberTable.selectAll().where {
(MemberTable.firstName.lowerCase() like "%${searchTerm.lowercase()}%") or
(MemberTable.lastName.lowerCase() like "%${searchTerm.lowercase()}%")
}
.limit(limit)
.map { rowToMember(it) }
}
override suspend fun findAllActive(limit: Int, offset: Int): List<Member> = DatabaseFactory.dbQuery {
MemberTable.selectAll().where { MemberTable.isActive eq true }
.limit(limit, offset.toLong())
.map { rowToMember(it) }
}
override suspend fun findAll(limit: Int, offset: Int): List<Member> = DatabaseFactory.dbQuery {
MemberTable.selectAll()
.limit(limit, offset.toLong())
.map { rowToMember(it) }
}
override suspend fun findByMembershipStartDateRange(startDate: LocalDate, endDate: LocalDate): List<Member> = DatabaseFactory.dbQuery {
MemberTable.selectAll().where {
(MemberTable.membershipStartDate greaterEq startDate) and
(MemberTable.membershipStartDate lessEq endDate)
}
.map { rowToMember(it) }
}
override suspend fun findByMembershipEndDateRange(startDate: LocalDate, endDate: LocalDate): List<Member> = DatabaseFactory.dbQuery {
MemberTable.selectAll().where {
(MemberTable.membershipEndDate.isNotNull()) and
(MemberTable.membershipEndDate greaterEq startDate) and
(MemberTable.membershipEndDate lessEq endDate)
}
.map { rowToMember(it) }
}
override suspend fun findMembersWithExpiringMembership(daysAhead: Int): List<Member> = DatabaseFactory.dbQuery {
val currentDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val futureDate = LocalDate(currentDate.year, currentDate.month, currentDate.dayOfMonth + daysAhead)
MemberTable.selectAll().where {
(MemberTable.membershipEndDate.isNotNull()) and
(MemberTable.membershipEndDate lessEq futureDate) and
(MemberTable.isActive eq true)
}
.map { rowToMember(it) }
}
override suspend fun save(member: Member): Member = DatabaseFactory.dbQuery {
val existingMember = MemberTable.selectAll().where { MemberTable.id eq member.memberId }.singleOrNull()
if (existingMember != null) {
// Update existing member
MemberTable.update({ MemberTable.id eq member.memberId }) {
it[firstName] = member.firstName
it[lastName] = member.lastName
it[email] = member.email
it[phone] = member.phone
it[dateOfBirth] = member.dateOfBirth
it[membershipNumber] = member.membershipNumber
it[membershipStartDate] = member.membershipStartDate
it[membershipEndDate] = member.membershipEndDate
it[isActive] = member.isActive
it[address] = member.address
it[emergencyContact] = member.emergencyContact
it[updatedAt] = Clock.System.now()
}
} else {
// Insert new member
MemberTable.insert {
it[id] = member.memberId
it[firstName] = member.firstName
it[lastName] = member.lastName
it[email] = member.email
it[phone] = member.phone
it[dateOfBirth] = member.dateOfBirth
it[membershipNumber] = member.membershipNumber
it[membershipStartDate] = member.membershipStartDate
it[membershipEndDate] = member.membershipEndDate
it[isActive] = member.isActive
it[address] = member.address
it[emergencyContact] = member.emergencyContact
}
}
member
}
override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery {
MemberTable.deleteWhere { MemberTable.id eq id } > 0
}
override suspend fun countActive(): Long = DatabaseFactory.dbQuery {
MemberTable.selectAll().where { MemberTable.isActive eq true }.count()
}
override suspend fun countAll(): Long = DatabaseFactory.dbQuery {
MemberTable.selectAll().count()
}
override suspend fun existsByMembershipNumber(membershipNumber: String, excludeMemberId: Uuid?): Boolean = DatabaseFactory.dbQuery {
val query = if (excludeMemberId != null) {
MemberTable.selectAll().where {
(MemberTable.membershipNumber eq membershipNumber) and
(MemberTable.id neq excludeMemberId)
}
} else {
MemberTable.selectAll().where { MemberTable.membershipNumber eq membershipNumber }
}
query.count() > 0
}
override suspend fun existsByEmail(email: String, excludeMemberId: Uuid?): Boolean = DatabaseFactory.dbQuery {
val query = if (excludeMemberId != null) {
MemberTable.selectAll().where {
(MemberTable.email.lowerCase() eq email.lowercase()) and
(MemberTable.id neq excludeMemberId)
}
} else {
MemberTable.selectAll().where { MemberTable.email.lowerCase() eq email.lowercase() }
}
query.count() > 0
}
private fun rowToMember(row: ResultRow): Member {
return Member(
memberId = row[MemberTable.id],
firstName = row[MemberTable.firstName],
lastName = row[MemberTable.lastName],
email = row[MemberTable.email],
phone = row[MemberTable.phone],
dateOfBirth = row[MemberTable.dateOfBirth],
membershipNumber = row[MemberTable.membershipNumber],
membershipStartDate = row[MemberTable.membershipStartDate],
membershipEndDate = row[MemberTable.membershipEndDate],
isActive = row[MemberTable.isActive],
address = row[MemberTable.address],
emergencyContact = row[MemberTable.emergencyContact]
)
}
}
@@ -1,31 +0,0 @@
package at.mocode.members.infrastructure.persistence
import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.sql.kotlin.datetime.date
import org.jetbrains.exposed.sql.kotlin.datetime.timestamp
import org.jetbrains.exposed.sql.kotlin.datetime.CurrentTimestamp
/**
* Database table definition for members in the member management context.
*
* This table stores member information including personal details,
* membership information, and contact details.
*/
object MemberTable : Table("members") {
val id = uuid("id").autoGenerate()
val firstName = varchar("first_name", 100)
val lastName = varchar("last_name", 100)
val email = varchar("email", 255).uniqueIndex()
val phone = varchar("phone", 50).nullable()
val dateOfBirth = date("date_of_birth").nullable()
val membershipNumber = varchar("membership_number", 50).uniqueIndex()
val membershipStartDate = date("membership_start_date")
val membershipEndDate = date("membership_end_date").nullable()
val isActive = bool("is_active").default(true)
val address = varchar("address", 500).nullable()
val emergencyContact = varchar("emergency_contact", 255).nullable()
val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp)
val updatedAt = timestamp("updated_at").defaultExpression(CurrentTimestamp)
override val primaryKey = PrimaryKey(id)
}
@@ -1,103 +0,0 @@
package at.mocode.members.infrastructure.repository
import at.mocode.members.domain.model.Member
import at.mocode.members.domain.repository.MemberRepository
import com.benasher44.uuid.Uuid
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import org.springframework.stereotype.Repository
import java.util.concurrent.ConcurrentHashMap
/**
* In-memory implementation of MemberRepository for development and testing purposes.
*/
@Repository
class InMemoryMemberRepository : MemberRepository {
private val members = ConcurrentHashMap<Uuid, Member>()
override suspend fun findById(id: Uuid): Member? {
return members[id]
}
override suspend fun findByMembershipNumber(membershipNumber: String): Member? {
return members.values.find { it.membershipNumber == membershipNumber }
}
override suspend fun findByEmail(email: String): Member? {
return members.values.find { it.email.equals(email, ignoreCase = true) }
}
override suspend fun findByName(searchTerm: String, limit: Int): List<Member> {
return members.values
.filter {
it.firstName.contains(searchTerm, ignoreCase = true) ||
it.lastName.contains(searchTerm, ignoreCase = true)
}
.take(limit)
}
override suspend fun findAllActive(limit: Int, offset: Int): List<Member> {
return members.values
.filter { it.isActive }
.drop(offset)
.take(limit)
}
override suspend fun findAll(limit: Int, offset: Int): List<Member> {
return members.values
.drop(offset)
.take(limit)
}
override suspend fun findByMembershipStartDateRange(startDate: LocalDate, endDate: LocalDate): List<Member> {
return members.values
.filter { it.membershipStartDate >= startDate && it.membershipStartDate <= endDate }
}
override suspend fun findByMembershipEndDateRange(startDate: LocalDate, endDate: LocalDate): List<Member> {
return members.values
.filter { member ->
member.membershipEndDate?.let { memberEndDate ->
memberEndDate >= startDate && memberEndDate <= endDate
} ?: false
}
}
override suspend fun findMembersWithExpiringMembership(daysAhead: Int): List<Member> {
// Simplified implementation - returns members with end dates set
return members.values
.filter { it.membershipEndDate != null }
}
override suspend fun save(member: Member): Member {
members[member.memberId] = member
return member
}
override suspend fun delete(id: Uuid): Boolean {
return members.remove(id) != null
}
override suspend fun countActive(): Long {
return members.values.count { it.isActive }.toLong()
}
override suspend fun countAll(): Long {
return members.size.toLong()
}
override suspend fun existsByMembershipNumber(membershipNumber: String, excludeMemberId: Uuid?): Boolean {
return members.values.any {
it.membershipNumber == membershipNumber && it.memberId != excludeMemberId
}
}
override suspend fun existsByEmail(email: String, excludeMemberId: Uuid?): Boolean {
return members.values.any {
it.email.equals(email, ignoreCase = true) && it.memberId != excludeMemberId
}
}
}
-56
View File
@@ -1,56 +0,0 @@
plugins {
// kotlin("jvm")
// kotlin("plugin.spring")
// id("org.springframework.boot")
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.spring)
// KORREKTUR: Dieses Plugin ist entscheidend. Es schaltet den `springBoot`-Block
// und alle Spring-Boot-spezifischen Gradle-Tasks frei.
alias(libs.plugins.spring.boot)
// Dependency Management für konsistente Spring-Versionen
alias(libs.plugins.spring.dependencyManagement)
}
springBoot {
mainClass.set("at.mocode.members.service.MembersServiceApplicationKt")
}
dependencies {
implementation(projects.platform.platformDependencies)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.members.membersDomain)
implementation(projects.members.membersApplication)
implementation(projects.members.membersInfrastructure)
implementation(projects.members.membersApi)
implementation(projects.infrastructure.auth.authClient)
implementation(projects.infrastructure.cache.redisCache)
implementation(projects.infrastructure.messaging.messagingClient)
implementation(projects.infrastructure.monitoring.monitoringClient)
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui")
// Database dependencies
implementation("org.jetbrains.exposed:exposed-core")
implementation("org.jetbrains.exposed:exposed-dao")
implementation("org.jetbrains.exposed:exposed-jdbc")
implementation("org.jetbrains.exposed:exposed-kotlin-datetime")
implementation("com.zaxxer:HikariCP")
runtimeOnly("org.postgresql:postgresql")
testRuntimeOnly("com.h2database:h2")
testImplementation(projects.platform.platformTesting)
testImplementation(libs.logback.classic) // SLF4J provider for tests
}
tasks.test {
useJUnitPlatform()
}
@@ -1,21 +0,0 @@
package at.mocode.members.service
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.ComponentScan
/**
* Main application class for the Members Service.
*
* This service provides APIs for managing members and their data.
*/
@SpringBootApplication
@ComponentScan(basePackages = ["at.mocode.members"])
class MembersServiceApplication
/**
* Main entry point for the Members Service application.
*/
fun main(args: Array<String>) {
runApplication<MembersServiceApplication>(*args)
}
@@ -1,104 +0,0 @@
package at.mocode.members.service.config
import at.mocode.core.utils.database.DatabaseConfig
import at.mocode.core.utils.database.DatabaseFactory
import at.mocode.members.infrastructure.persistence.MemberTable
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import org.slf4j.LoggerFactory
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
/**
* Database configuration for the Members Service.
*
* This configuration ensures that Database.connect() is called properly
* before any Exposed operations are performed.
*/
@Configuration
@Profile("!test")
class MembersDatabaseConfiguration {
private val log = LoggerFactory.getLogger(MembersDatabaseConfiguration::class.java)
@PostConstruct
fun initializeDatabase() {
log.info("Initializing database schema for Members Service...")
try {
// Database connection is already initialized by the gateway
// Only initialize the schema for this service
transaction {
SchemaUtils.createMissingTablesAndColumns(MemberTable)
log.info("Members database schema initialized successfully")
}
} catch (e: Exception) {
log.error("Failed to initialize database schema", e)
throw e
}
}
@PreDestroy
fun closeDatabase() {
log.info("Closing database connection for Members Service...")
try {
DatabaseFactory.close()
log.info("Database connection closed successfully")
} catch (e: Exception) {
log.error("Error closing database connection", e)
}
}
}
/**
* Test-specific database configuration.
*/
@Configuration
@Profile("test")
class MembersTestDatabaseConfiguration {
private val log = LoggerFactory.getLogger(MembersTestDatabaseConfiguration::class.java)
@PostConstruct
fun initializeTestDatabase() {
log.info("Initializing test database connection for Members Service...")
try {
// Use H2 in-memory database for tests
val testConfig = DatabaseConfig(
jdbcUrl = "jdbc:h2:mem:members_test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
username = "sa",
password = "",
driverClassName = "org.h2.Driver",
maxPoolSize = 5,
minPoolSize = 1,
autoMigrate = true
)
DatabaseFactory.init(testConfig)
log.info("Test database connection initialized successfully")
// Initialize database schema for tests
transaction {
SchemaUtils.createMissingTablesAndColumns(MemberTable)
log.info("Test members database schema initialized successfully")
}
} catch (e: Exception) {
log.error("Failed to initialize test database connection", e)
throw e
}
}
@PreDestroy
fun closeTestDatabase() {
log.info("Closing test database connection for Members Service...")
try {
DatabaseFactory.close()
log.info("Test database connection closed successfully")
} catch (e: Exception) {
log.error("Error closing test database connection", e)
}
}
}
@@ -1,245 +0,0 @@
package at.mocode.members.service.integration
import at.mocode.infrastructure.messaging.client.EventPublisher
import at.mocode.members.api.rest.MemberController
import at.mocode.members.domain.model.Member
import at.mocode.members.domain.repository.MemberRepository
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.TestPropertySource
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Integration tests for the Members Service.
*
* These tests verify the complete functionality including
* - REST API endpoints
* - Database operations
* - Event publishing
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@TestPropertySource(properties = [
"spring.datasource.url=jdbc:h2:mem:testdb",
"spring.kafka.bootstrap-servers=localhost:9092"
])
@ContextConfiguration(classes = [MemberServiceIntegrationTest.TestConfig::class])
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class MemberServiceIntegrationTest {
@Autowired
@Qualifier("memberRepositoryImpl")
private lateinit var memberRepository: MemberRepository
@Configuration
class TestConfig {
@Bean
fun eventPublisher(): EventPublisher = mockk(relaxed = true)
}
@BeforeEach
fun setUp() = runBlocking {
// Clean up database before each test
// Note: In a real implementation, you might want to use @Transactional or @DirtiesContext
println("[DEBUG_LOG] Setting up test - cleaning database")
}
@Test
fun `should create member successfully`() = runBlocking {
println("[DEBUG_LOG] Testing member creation")
// Given
val createRequest = MemberController.CreateMemberRequest(
firstName = "John",
lastName = "Doe",
email = "john.doe@example.com",
phone = "+43123456789",
dateOfBirth = LocalDate(1990, 1, 15),
membershipNumber = "M001",
membershipStartDate = LocalDate(2024, 1, 1),
membershipEndDate = null,
isActive = true,
address = "123 Test Street, Vienna",
emergencyContact = "Jane Doe: +43987654321"
)
// When
val member = Member(
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 savedMember = memberRepository.save(member)
// Then
assertNotNull(savedMember)
assertEquals(createRequest.firstName, savedMember.firstName)
assertEquals(createRequest.lastName, savedMember.lastName)
assertEquals(createRequest.email, savedMember.email)
assertEquals(createRequest.membershipNumber, savedMember.membershipNumber)
assertTrue(savedMember.isActive)
println("[DEBUG_LOG] Member created successfully with ID: ${savedMember.memberId}")
}
@Test
fun `should find member by membership number`() = runBlocking {
println("[DEBUG_LOG] Testing find member by membership number")
// Given
val member = Member(
firstName = "Jane",
lastName = "Smith",
email = "jane.smith@example.com",
membershipNumber = "M002",
membershipStartDate = LocalDate(2024, 1, 1),
isActive = true
)
memberRepository.save(member)
// When
val foundMember = memberRepository.findByMembershipNumber("M002")
// Then
assertNotNull(foundMember)
assertEquals("Jane", foundMember.firstName)
assertEquals("Smith", foundMember.lastName)
assertEquals("M002", foundMember.membershipNumber)
println("[DEBUG_LOG] Member found by membership number: ${foundMember.memberId}")
}
@Test
fun `should find member by email`() = runBlocking {
println("[DEBUG_LOG] Testing find member by email")
// Given
val member = Member(
firstName = "Bob",
lastName = "Johnson",
email = "bob.johnson@example.com",
membershipNumber = "M003",
membershipStartDate = LocalDate(2024, 1, 1),
isActive = true
)
memberRepository.save(member)
// When
val foundMember = memberRepository.findByEmail("bob.johnson@example.com")
// Then
assertNotNull(foundMember)
assertEquals("Bob", foundMember.firstName)
assertEquals("Johnson", foundMember.lastName)
assertEquals("bob.johnson@example.com", foundMember.email)
println("[DEBUG_LOG] Member found by email: ${foundMember.memberId}")
}
@Test
fun `should count active members`() = runBlocking {
println("[DEBUG_LOG] Testing count active members")
// Given
val activeMember = Member(
firstName = "Active",
lastName = "Member",
email = "active@example.com",
membershipNumber = "M004",
membershipStartDate = LocalDate(2024, 1, 1),
isActive = true
)
val inactiveMember = Member(
firstName = "Inactive",
lastName = "Member",
email = "inactive@example.com",
membershipNumber = "M005",
membershipStartDate = LocalDate(2024, 1, 1),
isActive = false
)
memberRepository.save(activeMember)
memberRepository.save(inactiveMember)
// When
val activeCount = memberRepository.countActive()
val totalCount = memberRepository.countAll()
// Then
assertTrue(activeCount >= 1, "Should have at least 1 active member")
assertTrue(totalCount >= 2, "Should have at least 2 total members")
println("[DEBUG_LOG] Active members: $activeCount, Total members: $totalCount")
}
@Test
fun `should validate duplicate membership number`() = runBlocking {
println("[DEBUG_LOG] Testing duplicate membership number validation")
// Given
val member1 = Member(
firstName = "First",
lastName = "Member",
email = "first@example.com",
membershipNumber = "M006",
membershipStartDate = LocalDate(2024, 1, 1),
isActive = true
)
memberRepository.save(member1)
// When
val exists = memberRepository.existsByMembershipNumber("M006")
// Then
assertTrue(exists, "Should detect existing membership number")
println("[DEBUG_LOG] Duplicate membership number validation passed")
}
@Test
fun `should validate duplicate email`() = runBlocking {
println("[DEBUG_LOG] Testing duplicate email validation")
// Given
val member = Member(
firstName = "Email",
lastName = "Test",
email = "email.test@example.com",
membershipNumber = "M007",
membershipStartDate = LocalDate(2024, 1, 1),
isActive = true
)
memberRepository.save(member)
// When
val exists = memberRepository.existsByEmail("email.test@example.com")
// Then
assertTrue(exists, "Should detect existing email")
println("[DEBUG_LOG] Duplicate email validation passed")
}
}
@@ -1,10 +0,0 @@
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
</configuration>