(vision) SCS/DDD

This commit is contained in:
2025-07-14 22:02:46 +02:00
parent f4b11b220d
commit 6e52015f46
54 changed files with 8849 additions and 262 deletions
@@ -0,0 +1,181 @@
package at.mocode.validation
import at.mocode.model.domaene.DomLizenz
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
/**
* Validator for DomLizenz objects
*/
object DomLizenzValidator {
/**
* Validates a DomLizenz object and returns validation result
*/
fun validate(lizenz: DomLizenz): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Length validations
ValidationUtils.validateLength(lizenz.notiz, "notiz", 500)?.let { errors.add(it) }
// Validity year validation
lizenz.gueltigBisJahr?.let { gueltigBisJahr ->
ValidationUtils.validateYear(gueltigBisJahr, "gueltigBisJahr", 2000)?.let { errors.add(it) }
}
// Issue date validation
lizenz.ausgestelltAm?.let { ausgestelltAm ->
ValidationUtils.validateBirthDate(ausgestelltAm, "ausgestelltAm")?.let { errors.add(it) }
}
// Business logic validations
validateBusinessRules(lizenz)?.let { errors.addAll(it) }
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
/**
* Validates business-specific rules for DomLizenz
*/
private fun validateBusinessRules(lizenz: DomLizenz): List<ValidationError>? {
val errors = mutableListOf<ValidationError>()
val currentYear = Clock.System.todayIn(TimeZone.currentSystemDefault()).year
// Active/paid licenses should have validity year
if (lizenz.istAktivBezahltOeps && lizenz.gueltigBisJahr == null) {
errors.add(ValidationError(
"gueltigBisJahr",
"Active/paid licenses should have validity year",
"REQUIRED_FOR_ACTIVE"
))
}
// Validity year should not be too far in the past for active licenses
lizenz.gueltigBisJahr?.let { gueltigBisJahr ->
if (lizenz.istAktivBezahltOeps && gueltigBisJahr < currentYear - 1) {
errors.add(ValidationError(
"gueltigBisJahr",
"Active license appears to be expired (validity year is more than 1 year in the past)",
"EXPIRED_LICENSE"
))
}
}
// Issue date should not be in the future
lizenz.ausgestelltAm?.let { ausgestelltAm ->
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
if (ausgestelltAm > today) {
errors.add(ValidationError(
"ausgestelltAm",
"Issue date cannot be in the future",
"FUTURE_DATE"
))
}
}
// Issue date and validity year consistency check
lizenz.ausgestelltAm?.let { ausgestelltAm ->
lizenz.gueltigBisJahr?.let { gueltigBisJahr ->
val issueYear = ausgestelltAm.year
// Validity year should be same or later than issue year
if (gueltigBisJahr < issueYear) {
errors.add(ValidationError(
"gueltigBisJahr",
"Validity year cannot be earlier than issue year",
"INVALID_DATE_RANGE"
))
}
// Validity year should not be too far from issue year (reasonable range)
if (gueltigBisJahr > issueYear + 10) {
errors.add(ValidationError(
"gueltigBisJahr",
"Validity year seems too far from issue year (more than 10 years)",
"SUSPICIOUS_DATE_RANGE"
))
}
}
}
// Inactive licenses should have a reason (note) if they were previously active
if (!lizenz.istAktivBezahltOeps && lizenz.notiz.isNullOrBlank()) {
// This is more of a recommendation than a hard error
errors.add(ValidationError(
"notiz",
"Inactive licenses should have a note explaining the status",
"RECOMMENDED_FOR_INACTIVE"
))
}
return if (errors.isEmpty()) null else errors
}
/**
* Validates license expiry status
*/
fun isLicenseExpired(lizenz: DomLizenz): Boolean {
val currentYear = Clock.System.todayIn(TimeZone.currentSystemDefault()).year
return lizenz.gueltigBisJahr?.let { it < currentYear } ?: false
}
/**
* Validates license validity for a specific year
*/
fun isValidForYear(lizenz: DomLizenz, year: Int): Boolean {
return lizenz.gueltigBisJahr?.let { it >= year } ?: false
}
/**
* Validates a DomLizenz and throws ValidationException if invalid
*/
fun validateAndThrow(lizenz: DomLizenz) {
val result = validate(lizenz)
if (result.isInvalid()) {
throw ValidationException(result as ValidationResult.Invalid)
}
}
/**
* Quick validation check - returns true if valid
*/
fun isValid(lizenz: DomLizenz): Boolean {
return validate(lizenz).isValid()
}
/**
* Validates multiple licenses for a person to check for conflicts
*/
fun validateLicenseSet(lizenzen: List<DomLizenz>): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Check for duplicate license types for the same person and year
val licenseTypeYearCombinations = mutableSetOf<Pair<String, Int?>>()
lizenzen.forEachIndexed { index, lizenz ->
val combination = Pair(lizenz.lizenzTypGlobalId.toString(), lizenz.gueltigBisJahr)
if (combination in licenseTypeYearCombinations) {
errors.add(ValidationError(
"lizenzen[$index]",
"Duplicate license type for the same validity year",
"DUPLICATE_LICENSE"
))
} else {
licenseTypeYearCombinations.add(combination)
}
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
}
@@ -0,0 +1,185 @@
package at.mocode.validation
import at.mocode.model.domaene.DomPferd
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
/**
* Validator for DomPferd objects
*/
object DomPferdValidator {
/**
* Validates a DomPferd object and returns validation result
*/
fun validate(pferd: DomPferd): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Required fields validation
ValidationUtils.validateNotBlank(pferd.name, "name")?.let { errors.add(it) }
// Length validations
ValidationUtils.validateLength(pferd.name, "name", 100, 1)?.let { errors.add(it) }
ValidationUtils.validateLength(pferd.lebensnummer, "lebensnummer", 20)?.let { errors.add(it) }
ValidationUtils.validateLength(pferd.feiPassNr, "feiPassNr", 20)?.let { errors.add(it) }
ValidationUtils.validateLength(pferd.farbe, "farbe", 50)?.let { errors.add(it) }
ValidationUtils.validateLength(pferd.rasse, "rasse", 100)?.let { errors.add(it) }
ValidationUtils.validateLength(pferd.abstammungVaterName, "abstammungVaterName", 100)?.let { errors.add(it) }
ValidationUtils.validateLength(pferd.abstammungMutterName, "abstammungMutterName", 100)?.let { errors.add(it) }
ValidationUtils.validateLength(pferd.abstammungMutterVaterName, "abstammungMutterVaterName", 100)?.let { errors.add(it) }
ValidationUtils.validateLength(pferd.abstammungZusatzInfo, "abstammungZusatzInfo", 500)?.let { errors.add(it) }
ValidationUtils.validateLength(pferd.notizenIntern, "notizenIntern", 1000)?.let { errors.add(it) }
// OEPS Satznummer validation (10-digit number)
pferd.oepsSatzNrPferd?.let { oepsSatzNr ->
if (oepsSatzNr.isNotBlank()) {
if (oepsSatzNr.length != 10 || !oepsSatzNr.all { it.isDigit() }) {
errors.add(ValidationError(
"oepsSatzNrPferd",
"OEPS Satznummer must be exactly 10 digits",
"INVALID_FORMAT"
))
}
}
}
// OEPS Kopfnummer validation (4-digit number)
pferd.oepsKopfNr?.let { oepsKopfNr ->
if (oepsKopfNr.isNotBlank()) {
if (oepsKopfNr.length != 4 || !oepsKopfNr.all { it.isDigit() }) {
errors.add(ValidationError(
"oepsKopfNr",
"OEPS Kopfnummer must be exactly 4 digits",
"INVALID_FORMAT"
))
}
}
}
// Lebensnummer validation (UELN format - basic validation)
pferd.lebensnummer?.let { lebensnummer ->
if (lebensnummer.isNotBlank()) {
// UELN should be 15 characters: 3-letter country code + 12 digits
if (lebensnummer.length != 15 ||
!lebensnummer.substring(0, 3).all { it.isLetter() } ||
!lebensnummer.substring(3).all { it.isDigit() }) {
errors.add(ValidationError(
"lebensnummer",
"Lebensnummer (UELN) must be 15 characters: 3 letters + 12 digits",
"INVALID_FORMAT"
))
}
}
}
// Birth year validation
pferd.geburtsjahr?.let { geburtsjahr ->
ValidationUtils.validateYear(geburtsjahr, "geburtsjahr", 1950)?.let { errors.add(it) }
}
// Payment year validation
pferd.letzteZahlungPferdegebuehrJahrOeps?.let { zahlungsjahr ->
ValidationUtils.validateYear(zahlungsjahr, "letzteZahlungPferdegebuehrJahrOeps", 1990)?.let { errors.add(it) }
}
// Stockmaß validation (reasonable range for horses)
pferd.stockmassCm?.let { stockmass ->
if (stockmass < 80 || stockmass > 220) {
errors.add(ValidationError(
"stockmassCm",
"Stockmaß must be between 80 and 220 cm",
"INVALID_RANGE"
))
}
}
// Business logic validations
validateBusinessRules(pferd)?.let { errors.addAll(it) }
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
/**
* Validates business-specific rules for DomPferd
*/
private fun validateBusinessRules(pferd: DomPferd): List<ValidationError>? {
val errors = mutableListOf<ValidationError>()
// OEPS horses should have OEPS numbers
if (pferd.datenQuelle.name.contains("OEPS") && pferd.oepsSatzNrPferd.isNullOrBlank()) {
errors.add(ValidationError(
"oepsSatzNrPferd",
"OEPS horses should have OEPS Satznummer",
"REQUIRED_FOR_OEPS"
))
}
// Active horses should have birth year
if (pferd.istAktiv && pferd.geburtsjahr == null) {
errors.add(ValidationError(
"geburtsjahr",
"Birth year is recommended for active horses",
"RECOMMENDED_FOR_ACTIVE"
))
}
// Active horses should have gender
if (pferd.istAktiv && pferd.geschlecht == null) {
errors.add(ValidationError(
"geschlecht",
"Gender is recommended for active horses",
"RECOMMENDED_FOR_ACTIVE"
))
}
// Horses with payment info should have birth year for age verification
pferd.letzteZahlungPferdegebuehrJahrOeps?.let { zahlungsjahr ->
pferd.geburtsjahr?.let { geburtsjahr ->
val currentYear = Clock.System.todayIn(TimeZone.currentSystemDefault()).year
val age = currentYear - geburtsjahr
// Horses should be at least 3 years old for competition
if (age < 3) {
errors.add(ValidationError(
"geburtsjahr",
"Horse appears to be too young for competition (under 3 years)",
"AGE_WARNING"
))
}
// Warning for very old horses
if (age > 30) {
errors.add(ValidationError(
"geburtsjahr",
"Horse appears to be very old (over 30 years)",
"AGE_WARNING"
))
}
}
}
return if (errors.isEmpty()) null else errors
}
/**
* Validates a DomPferd and throws ValidationException if invalid
*/
fun validateAndThrow(pferd: DomPferd) {
val result = validate(pferd)
if (result.isInvalid()) {
throw ValidationException(result as ValidationResult.Invalid)
}
}
/**
* Quick validation check - returns true if valid
*/
fun isValid(pferd: DomPferd): Boolean {
return validate(pferd).isValid()
}
}
@@ -0,0 +1,238 @@
package at.mocode.validation
import at.mocode.model.domaene.DomQualifikation
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
/**
* Validator for DomQualifikation objects
*/
object DomQualifikationValidator {
/**
* Validates a DomQualifikation object and returns validation result
*/
fun validate(qualifikation: DomQualifikation): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Length validations
ValidationUtils.validateLength(qualifikation.bemerkung, "bemerkung", 500)?.let { errors.add(it) }
// Date validations - basic date range validation (not birth date validation)
qualifikation.gueltigVon?.let { gueltigVon ->
// Only check that it's not too far in the past (reasonable minimum date)
val minDate = kotlinx.datetime.LocalDate(1900, 1, 1)
if (gueltigVon < minDate) {
errors.add(ValidationError(
"gueltigVon",
"Start date cannot be before year 1900",
"INVALID_DATE"
))
}
}
qualifikation.gueltigBis?.let { gueltigBis ->
// Only check that it's not too far in the past (reasonable minimum date)
val minDate = kotlinx.datetime.LocalDate(1900, 1, 1)
if (gueltigBis < minDate) {
errors.add(ValidationError(
"gueltigBis",
"End date cannot be before year 1900",
"INVALID_DATE"
))
}
}
// Business logic validations
validateBusinessRules(qualifikation)?.let { errors.addAll(it) }
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
/**
* Validates business-specific rules for DomQualifikation
*/
private fun validateBusinessRules(qualifikation: DomQualifikation): List<ValidationError>? {
val errors = mutableListOf<ValidationError>()
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
// Validity date range consistency check
qualifikation.gueltigVon?.let { gueltigVon ->
qualifikation.gueltigBis?.let { gueltigBis ->
if (gueltigBis < gueltigVon) {
errors.add(ValidationError(
"gueltigBis",
"End date cannot be earlier than start date",
"INVALID_DATE_RANGE"
))
}
// Check for unreasonably long qualification periods
val daysBetween = gueltigBis.toEpochDays() - gueltigVon.toEpochDays()
if (daysBetween > 365 * 20) { // More than 20 years
errors.add(ValidationError(
"gueltigBis",
"Qualification validity period seems unreasonably long (more than 20 years)",
"SUSPICIOUS_DATE_RANGE"
))
}
}
}
// Start date should not be in the future for active qualifications
qualifikation.gueltigVon?.let { gueltigVon ->
if (qualifikation.istAktiv && gueltigVon > today) {
errors.add(ValidationError(
"gueltigVon",
"Start date cannot be in the future for active qualifications",
"FUTURE_DATE"
))
}
}
// Active qualifications with end date should not be expired
if (qualifikation.istAktiv) {
qualifikation.gueltigBis?.let { gueltigBis ->
if (gueltigBis < today) {
errors.add(ValidationError(
"istAktiv",
"Qualification appears to be expired but is marked as active",
"EXPIRED_QUALIFICATION"
))
}
}
}
// Inactive qualifications should have a reason (note)
if (!qualifikation.istAktiv && qualifikation.bemerkung.isNullOrBlank()) {
errors.add(ValidationError(
"bemerkung",
"Inactive qualifications should have a note explaining the status",
"RECOMMENDED_FOR_INACTIVE"
))
}
// Active qualifications should have start date
if (qualifikation.istAktiv && qualifikation.gueltigVon == null) {
errors.add(ValidationError(
"gueltigVon",
"Active qualifications should have a start date",
"RECOMMENDED_FOR_ACTIVE"
))
}
return if (errors.isEmpty()) null else errors
}
/**
* Validates qualification expiry status
*/
fun isQualificationExpired(qualifikation: DomQualifikation): Boolean {
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
return qualifikation.gueltigBis?.let { it < today } ?: false
}
/**
* Validates qualification validity for a specific date
*/
fun isValidForDate(qualifikation: DomQualifikation, date: kotlinx.datetime.LocalDate): Boolean {
val validFrom = qualifikation.gueltigVon?.let { date >= it } ?: true
val validUntil = qualifikation.gueltigBis?.let { date <= it } ?: true
return validFrom && validUntil && qualifikation.istAktiv
}
/**
* Validates qualification validity for today
*/
fun isCurrentlyValid(qualifikation: DomQualifikation): Boolean {
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
return isValidForDate(qualifikation, today)
}
/**
* Validates a DomQualifikation and throws ValidationException if invalid
*/
fun validateAndThrow(qualifikation: DomQualifikation) {
val result = validate(qualifikation)
if (result.isInvalid()) {
throw ValidationException(result as ValidationResult.Invalid)
}
}
/**
* Quick validation check - returns true if valid
*/
fun isValid(qualifikation: DomQualifikation): Boolean {
return validate(qualifikation).isValid()
}
/**
* Validates multiple qualifications for a person to check for conflicts
*/
fun validateQualificationSet(qualifikationen: List<DomQualifikation>): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Check for overlapping active qualifications of the same type
val activeQualifications = qualifikationen.filter { it.istAktiv }
for (i in activeQualifications.indices) {
for (j in i + 1 until activeQualifications.size) {
val qual1 = activeQualifications[i]
val qual2 = activeQualifications[j]
// Same qualification type
if (qual1.qualTypId == qual2.qualTypId) {
// Check for overlapping periods
val overlap = checkDateOverlap(
qual1.gueltigVon, qual1.gueltigBis,
qual2.gueltigVon, qual2.gueltigBis
)
if (overlap) {
errors.add(ValidationError(
"qualifikationen",
"Overlapping active qualifications of the same type found",
"OVERLAPPING_QUALIFICATIONS"
))
}
}
}
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
/**
* Helper function to check if two date ranges overlap
*/
private fun checkDateOverlap(
start1: kotlinx.datetime.LocalDate?, end1: kotlinx.datetime.LocalDate?,
start2: kotlinx.datetime.LocalDate?, end2: kotlinx.datetime.LocalDate?
): Boolean {
// If any qualification has no dates, assume no overlap
if (start1 == null && end1 == null) return false
if (start2 == null && end2 == null) return false
// Use very early/late dates for missing bounds
val earlyDate = kotlinx.datetime.LocalDate(1900, 1, 1)
val lateDate = kotlinx.datetime.LocalDate(2100, 12, 31)
val actualStart1 = start1 ?: earlyDate
val actualEnd1 = end1 ?: lateDate
val actualStart2 = start2 ?: earlyDate
val actualEnd2 = end2 ?: lateDate
// Check if ranges overlap
return actualStart1 <= actualEnd2 && actualStart2 <= actualEnd1
}
}
@@ -0,0 +1,103 @@
package at.mocode.validation
import at.mocode.model.domaene.DomVerein
/**
* Validator for DomVerein objects
*/
object DomVereinValidator {
/**
* Validates a DomVerein object and returns validation result
*/
fun validate(verein: DomVerein): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Required fields validation
ValidationUtils.validateNotBlank(verein.name, "name")?.let { errors.add(it) }
// Length validations
ValidationUtils.validateLength(verein.name, "name", 100, 1)?.let { errors.add(it) }
ValidationUtils.validateLength(verein.kuerzel, "kuerzel", 20)?.let { errors.add(it) }
ValidationUtils.validateLength(verein.adresseStrasse, "adresseStrasse", 200)?.let { errors.add(it) }
ValidationUtils.validateLength(verein.ort, "ort", 100)?.let { errors.add(it) }
ValidationUtils.validateLength(verein.webseiteUrl, "webseiteUrl", 255)?.let { errors.add(it) }
ValidationUtils.validateLength(verein.notizenIntern, "notizenIntern", 1000)?.let { errors.add(it) }
// Format validations
ValidationUtils.validateEmail(verein.emailAllgemein, "emailAllgemein")?.let { errors.add(it) }
ValidationUtils.validatePhoneNumber(verein.telefonAllgemein, "telefonAllgemein")?.let { errors.add(it) }
ValidationUtils.validatePostalCode(verein.plz, "plz")?.let { errors.add(it) }
// OEPS Vereinsnummer validation (4-digit number)
verein.oepsVereinsNr?.let { oepsNr ->
if (oepsNr.isNotBlank()) {
if (oepsNr.length != 4 || !oepsNr.all { it.isDigit() }) {
errors.add(ValidationError(
"oepsVereinsNr",
"OEPS Vereinsnummer must be exactly 4 digits",
"INVALID_FORMAT"
))
}
}
}
// Website URL validation
verein.webseiteUrl?.let { url ->
if (url.isNotBlank()) {
val urlRegex = "^https?://[\\w\\-]+(\\.[\\w\\-]+)+([\\w\\-\\.,@?^=%&:/~\\+#]*[\\w\\-\\@?^=%&/~\\+#])?$".toRegex()
if (!urlRegex.matches(url)) {
errors.add(ValidationError(
"webseiteUrl",
"Invalid website URL format",
"INVALID_FORMAT"
))
}
}
}
// Business logic validations
validateBusinessRules(verein)?.let { errors.addAll(it) }
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
/**
* Validates business-specific rules for DomVerein
*/
private fun validateBusinessRules(verein: DomVerein): List<ValidationError>? {
val errors = mutableListOf<ValidationError>()
// OEPS clubs should have OEPS number
if (verein.datenQuelle.name.contains("OEPS") && verein.oepsVereinsNr.isNullOrBlank()) {
errors.add(ValidationError(
"oepsVereinsNr",
"OEPS clubs should have OEPS Vereinsnummer",
"REQUIRED_FOR_OEPS"
))
}
return if (errors.isEmpty()) null else errors
}
/**
* Validates a DomVerein and throws ValidationException if invalid
*/
fun validateAndThrow(verein: DomVerein) {
val result = validate(verein)
if (result.isInvalid()) {
throw ValidationException(result as ValidationResult.Invalid)
}
}
/**
* Quick validation check - returns true if valid
*/
fun isValid(verein: DomVerein): Boolean {
return validate(verein).isValid()
}
}
@@ -0,0 +1,131 @@
package at.mocode.validation
import at.mocode.stammdaten.Person
/**
* Validator for Person objects
*/
object PersonValidator {
/**
* Validates a Person object and returns validation result
*/
fun validate(person: Person): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Required fields validation
ValidationUtils.validateNotBlank(person.vorname, "vorname")?.let { errors.add(it) }
ValidationUtils.validateNotBlank(person.nachname, "nachname")?.let { errors.add(it) }
// Length validations
ValidationUtils.validateLength(person.vorname, "vorname", 100, 1)?.let { errors.add(it) }
ValidationUtils.validateLength(person.nachname, "nachname", 100, 1)?.let { errors.add(it) }
ValidationUtils.validateLength(person.titel, "titel", 50)?.let { errors.add(it) }
ValidationUtils.validateLength(person.adresse, "adresse", 500)?.let { errors.add(it) }
ValidationUtils.validateLength(person.ort, "ort", 100)?.let { errors.add(it) }
ValidationUtils.validateLength(person.mitgliedsNummerIntern, "mitgliedsNummerIntern", 50)?.let { errors.add(it) }
ValidationUtils.validateLength(person.feiId, "feiId", 50)?.let { errors.add(it) }
ValidationUtils.validateLength(person.sperrGrund, "sperrGrund", 500)?.let { errors.add(it) }
// Format validations
ValidationUtils.validateEmail(person.email)?.let { errors.add(it) }
ValidationUtils.validatePhoneNumber(person.telefon)?.let { errors.add(it) }
ValidationUtils.validatePostalCode(person.plz)?.let { errors.add(it) }
ValidationUtils.validateCountryCode(person.nationalitaet)?.let { errors.add(it) }
ValidationUtils.validateOepsSatzNr(person.oepsSatzNr)?.let { errors.add(it) }
// Date validations
ValidationUtils.validateBirthDate(person.geburtsdatum)?.let { errors.add(it) }
ValidationUtils.validateYear(person.letzteZahlungJahr, "letzteZahlungJahr", 1990)?.let { errors.add(it) }
// Business logic validations
validateBusinessRules(person)?.let { errors.addAll(it) }
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
/**
* Validates business-specific rules for Person
*/
private fun validateBusinessRules(person: Person): List<ValidationError>? {
val errors = mutableListOf<ValidationError>()
// If person is blocked, there must be a reason
if (person.istGesperrt && person.sperrGrund.isNullOrBlank()) {
errors.add(ValidationError(
"sperrGrund",
"Block reason is required when person is blocked",
"REQUIRED_WHEN_BLOCKED"
))
}
// If person is not blocked, there shouldn't be a block reason
if (!person.istGesperrt && !person.sperrGrund.isNullOrBlank()) {
errors.add(ValidationError(
"sperrGrund",
"Block reason should be empty when person is not blocked",
"INVALID_WHEN_NOT_BLOCKED"
))
}
// Email is required for active persons (business rule example)
if (person.istAktiv && person.email.isNullOrBlank()) {
errors.add(ValidationError(
"email",
"Email is required for active persons",
"REQUIRED_FOR_ACTIVE"
))
}
// Validate license information consistency
person.lizenzen.forEachIndexed { index, lizenz ->
// Validate license level if provided
lizenz.stufe?.let { stufe ->
if (stufe.isBlank()) {
errors.add(ValidationError(
"lizenzen[$index].stufe",
"License level cannot be blank if provided",
"REQUIRED"
))
}
if (stufe.length > 50) {
errors.add(ValidationError(
"lizenzen[$index].stufe",
"License level cannot exceed 50 characters",
"MAX_LENGTH"
))
}
}
// Validate license validity year
lizenz.gueltigBisJahr?.let { jahr ->
ValidationUtils.validateYear(jahr, "lizenzen[$index].gueltigBisJahr", 2000)?.let {
errors.add(it)
}
}
}
return if (errors.isEmpty()) null else errors
}
/**
* Validates a Person and throws ValidationException if invalid
*/
fun validateAndThrow(person: Person) {
val result = validate(person)
if (result.isInvalid()) {
throw ValidationException(result as ValidationResult.Invalid)
}
}
/**
* Quick validation check - returns true if valid
*/
fun isValid(person: Person): Boolean {
return validate(person).isValid()
}
}
@@ -0,0 +1,37 @@
package at.mocode.validation
import kotlinx.serialization.Serializable
/**
* Represents the result of a validation operation
*/
@Serializable
sealed class ValidationResult {
@Serializable
object Valid : ValidationResult()
@Serializable
data class Invalid(val errors: List<ValidationError>) : ValidationResult()
fun isValid(): Boolean = this is Valid
fun isInvalid(): Boolean = this is Invalid
}
/**
* Represents a single validation error
*/
@Serializable
data class ValidationError(
val field: String,
val message: String,
val code: String? = null
)
/**
* Exception thrown when validation fails
*/
class ValidationException(
val validationResult: ValidationResult.Invalid
) : IllegalArgumentException(
"Validation failed: ${validationResult.errors.joinToString(", ") { "${it.field}: ${it.message}" }}"
)
@@ -0,0 +1,150 @@
package at.mocode.validation
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
/**
* Common validation utilities
*/
object ValidationUtils {
/**
* Validates that a string is not blank
*/
fun validateNotBlank(value: String?, fieldName: String): ValidationError? {
return if (value.isNullOrBlank()) {
ValidationError(fieldName, "$fieldName cannot be blank", "REQUIRED")
} else null
}
/**
* Validates string length
*/
fun validateLength(value: String?, fieldName: String, maxLength: Int, minLength: Int = 0): ValidationError? {
if (value == null) return null
return when {
value.length < minLength -> ValidationError(
fieldName,
"$fieldName must be at least $minLength characters long",
"MIN_LENGTH"
)
value.length > maxLength -> ValidationError(
fieldName,
"$fieldName cannot exceed $maxLength characters",
"MAX_LENGTH"
)
else -> null
}
}
/**
* Validates email format
*/
fun validateEmail(email: String?, fieldName: String = "email"): ValidationError? {
if (email.isNullOrBlank()) return null
val emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$".toRegex()
return if (!emailRegex.matches(email)) {
ValidationError(fieldName, "Invalid email format", "INVALID_FORMAT")
} else null
}
/**
* Validates phone number format (basic validation)
*/
fun validatePhoneNumber(phone: String?, fieldName: String = "telefon"): ValidationError? {
if (phone.isNullOrBlank()) return null
// Remove common separators and spaces
val cleanPhone = phone.replace(Regex("[\\s\\-\\(\\)\\+]"), "")
return if (cleanPhone.length < 6 || cleanPhone.length > 20 || !cleanPhone.all { it.isDigit() }) {
ValidationError(fieldName, "Invalid phone number format", "INVALID_FORMAT")
} else null
}
/**
* Validates postal code format (basic validation for various countries)
*/
fun validatePostalCode(postalCode: String?, fieldName: String = "plz"): ValidationError? {
if (postalCode.isNullOrBlank()) return null
// Basic validation: 3-10 alphanumeric characters
return if (postalCode.length < 3 || postalCode.length > 10 || !postalCode.all { it.isLetterOrDigit() }) {
ValidationError(fieldName, "Invalid postal code format", "INVALID_FORMAT")
} else null
}
/**
* Validates 3-letter country code
*/
fun validateCountryCode(countryCode: String?, fieldName: String = "nationalitaet"): ValidationError? {
if (countryCode.isNullOrBlank()) return null
return if (countryCode.length != 3 || !countryCode.all { it.isLetter() }) {
ValidationError(fieldName, "Country code must be exactly 3 letters", "INVALID_FORMAT")
} else null
}
/**
* Validates birth date
*/
fun validateBirthDate(birthDate: LocalDate?, fieldName: String = "geburtsdatum"): ValidationError? {
if (birthDate == null) return null
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
val minDate = LocalDate(1900, 1, 1)
return when {
birthDate > today -> ValidationError(
fieldName,
"Birth date cannot be in the future",
"FUTURE_DATE"
)
birthDate < minDate -> ValidationError(
fieldName,
"Birth date cannot be before year 1900",
"INVALID_DATE"
)
else -> null
}
}
/**
* Validates year value
*/
fun validateYear(year: Int?, fieldName: String, minYear: Int = 1900): ValidationError? {
if (year == null) return null
val currentYear = Clock.System.todayIn(TimeZone.currentSystemDefault()).year
return when {
year < minYear -> ValidationError(
fieldName,
"Year cannot be before $minYear",
"INVALID_YEAR"
)
year > currentYear + 10 -> ValidationError(
fieldName,
"Year cannot be more than 10 years in the future",
"FUTURE_YEAR"
)
else -> null
}
}
/**
* Validates OEPS Satz number format (Austrian specific)
*/
fun validateOepsSatzNr(oepsSatzNr: String?, fieldName: String = "oepsSatzNr"): ValidationError? {
if (oepsSatzNr.isNullOrBlank()) return null
// Basic validation: should be numeric and reasonable length
return if (oepsSatzNr.length < 3 || oepsSatzNr.length > 20 || !oepsSatzNr.all { it.isDigit() }) {
ValidationError(fieldName, "Invalid OEPS Satz number format", "INVALID_FORMAT")
} else null
}
}