diff --git a/.gitignore b/.gitignore index dcb6c75..6e9e444 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,5 @@ bin/ .idea/* .env -data/* \ No newline at end of file +data/* +testSuite/* \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index e7ad953..633ec19 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -51,6 +51,9 @@ dependencies { implementation("org.jetbrains.kotlin:kotlin-reflect") kapt("org.springframework.boot:spring-boot-configuration-processor") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.0") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + // implementation("io.jsonwebtoken:jjwt-api:0.12.6") // runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6") // runtimeOnly("io.jsonwebtoken:jjwt-gson:0.12.6") diff --git a/src/main/kotlin/org/octopus/internal/common/models/Entry.kt b/src/main/kotlin/org/octopus/internal/common/models/Entry.kt index 70621c4..6a36b48 100755 --- a/src/main/kotlin/org/octopus/internal/common/models/Entry.kt +++ b/src/main/kotlin/org/octopus/internal/common/models/Entry.kt @@ -1,15 +1,38 @@ package org.octopus.internal.common.models +import com.fasterxml.jackson.annotation.JsonGetter +import com.fasterxml.jackson.annotation.JsonIgnore +import io.swagger.v3.oas.annotations.media.Schema import org.octopus.internal.common.enums.EEntryType import java.time.Month import java.util.* data class Entry( + @field:Schema(description = "The id of the entry in the DB") var id: Long?, + @field:Schema(description = "The name of the entry (between 1 and 50 characters long).") + val name: String, + @field:Schema(description = "The type of entry (INCOME, OUTCOME, INVESTMENT).") val type: EEntryType, + @field:Schema(description = "The amount of the entry (greater than 0.00).") val amount: Double, + @field:Schema(description = "IF NOT recurrent: when is the entry being counted.") val fixedDate: Date? = null, + @field:Schema(description = "IF recurrent: must be true") val recurrent: Boolean? = false, + @field:Schema(description = "IF recurrent: list of month in which the event is repeated (1=JAN, 12=DEC)") + @get:JsonIgnore val recurrentMonths: List? = mutableListOf(), - val endDate: Date? = null -) \ No newline at end of file + @field:Schema(description = "IF recurrent: starting date of the repetition") + val startDate: Date? = null, + @field:Schema(description = "IF recurrent: ending date of the repetition") + val endDate: Date? = null, + @field:Schema(description = "Color of the entry in the UI") + val color: String +){ + + @JsonGetter("recurrentMonths") + fun getRecurrentMonthsAsInts(): List? { + return this.recurrentMonths?.map { it.value } + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/octopus/internal/db/converters/MonthListConverter.kt b/src/main/kotlin/org/octopus/internal/db/converters/MonthListConverter.kt new file mode 100644 index 0000000..e27f8be --- /dev/null +++ b/src/main/kotlin/org/octopus/internal/db/converters/MonthListConverter.kt @@ -0,0 +1,19 @@ +package org.octopus.internal.db.converters + +import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter +import java.time.Month + +@Converter +class MonthListConverter : AttributeConverter, String> { + + override fun convertToDatabaseColumn(attribute: List?): String? { + return attribute?.joinToString(separator = ",") { it.name } + } + + override fun convertToEntityAttribute(dbData: String?): List? { + return dbData?.split(",") + ?.filter { it.isNotBlank() } + ?.mapNotNull { runCatching { Month.valueOf(it.trim()) }.getOrNull() } + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/octopus/internal/db/entities/EntryEntity.kt b/src/main/kotlin/org/octopus/internal/db/entities/EntryEntity.kt index d6487eb..63be77b 100644 --- a/src/main/kotlin/org/octopus/internal/db/entities/EntryEntity.kt +++ b/src/main/kotlin/org/octopus/internal/db/entities/EntryEntity.kt @@ -1,10 +1,12 @@ package org.octopus.internal.db.entities +import jakarta.persistence.Convert import jakarta.persistence.Entity import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id import org.octopus.internal.common.enums.EEntryType +import org.octopus.internal.db.converters.MonthListConverter import java.time.Month import java.util.Date @@ -12,10 +14,14 @@ import java.util.Date data class EntryEntity( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Long = 0L, + val name: String, val type: EEntryType, val amount: Double, val fixedDate: Date? = null, val recurrent: Boolean? = false, + @field:Convert(converter = MonthListConverter::class) val recurrentMonths: List? = mutableListOf(), - val endDate: Date? = null + val startDate: Date? = null, + val endDate: Date? = null, + val color: String ) \ No newline at end of file diff --git a/src/main/kotlin/org/octopus/internal/repositories/EntryRepository.kt b/src/main/kotlin/org/octopus/internal/repositories/EntryRepository.kt index 3a3cdd5..257c97a 100755 --- a/src/main/kotlin/org/octopus/internal/repositories/EntryRepository.kt +++ b/src/main/kotlin/org/octopus/internal/repositories/EntryRepository.kt @@ -13,4 +13,7 @@ interface EntryRepository { fun getAllRecurrentOnAllMonths(): MutableList fun getAllFixedUpToEndDate(endDate: Date): MutableList fun getAllByType(type: EEntryType): MutableList + + fun deleteById(id: Long): Boolean + fun deleteAll(): Boolean } diff --git a/src/main/kotlin/org/octopus/internal/repositories/impl/EntryRepositoryImpl.kt b/src/main/kotlin/org/octopus/internal/repositories/impl/EntryRepositoryImpl.kt index 3121ade..dc0aed4 100755 --- a/src/main/kotlin/org/octopus/internal/repositories/impl/EntryRepositoryImpl.kt +++ b/src/main/kotlin/org/octopus/internal/repositories/impl/EntryRepositoryImpl.kt @@ -37,6 +37,17 @@ class EntryRepositoryImpl( return mapper.toModels(jpa.findAllByType(type)) } + override fun deleteById(id: Long): Boolean { + jpa.deleteById(id) + return true + + } + + override fun deleteAll(): Boolean { + jpa.deleteAll() + return true + } + override fun getAllRecurrentMonthlyToEndDate(endDate: Date): MutableList { return mapper.toModels( jpa.findAllByFixedDateIsNullAndRecurrentIsTrueAndEndDateIsNotNullAndEndDateBefore(endDate) diff --git a/src/main/kotlin/org/octopus/internal/services/EntryService.kt b/src/main/kotlin/org/octopus/internal/services/EntryService.kt index f6416fc..4042900 100755 --- a/src/main/kotlin/org/octopus/internal/services/EntryService.kt +++ b/src/main/kotlin/org/octopus/internal/services/EntryService.kt @@ -4,6 +4,9 @@ import org.octopus.internal.common.models.Entry import org.octopus.internal.web.utils.dtos.EntryDto interface EntryService { - fun createEntry(entry: EntryDto) + fun createEntry(entry: EntryDto): Entry fun getAllEntries(): MutableList + fun deleteAll(): Boolean + fun deleteById(id: Long): Boolean + fun editEntry(id: Long, entry: EntryDto): Entry } \ No newline at end of file diff --git a/src/main/kotlin/org/octopus/internal/services/impl/EntryServiceImpl.kt b/src/main/kotlin/org/octopus/internal/services/impl/EntryServiceImpl.kt index 6511374..1535903 100755 --- a/src/main/kotlin/org/octopus/internal/services/impl/EntryServiceImpl.kt +++ b/src/main/kotlin/org/octopus/internal/services/impl/EntryServiceImpl.kt @@ -1,20 +1,82 @@ package org.octopus.internal.services.impl +import org.octopus.internal.common.enums.EBusinessException import org.octopus.internal.common.enums.EEntryType +import org.octopus.internal.common.exceptions.OctopusPlanningException import org.octopus.internal.common.models.Entry import org.octopus.internal.repositories.EntryRepository import org.octopus.internal.services.EntryService import org.octopus.internal.web.utils.dtos.EntryDto import org.springframework.stereotype.Service +import java.time.Month @Service class EntryServiceImpl(val repo: EntryRepository) : EntryService { - override fun createEntry(entry: EntryDto) { - TODO("Not yet implemented") + override fun createEntry(entry: EntryDto): Entry { + if (!verifyDto(entry)) { + throw OctopusPlanningException.create(EBusinessException.INVALID_REQUEST, entry, "Entry must be either recurring or fixed.") + } + + return repo.createEntry(Entry( + id = 0L, + name = entry.name, + type = EEntryType.valueOf(entry.type), + amount = entry.amount, + fixedDate = entry.fixedDate, + startDate = entry.startDate, + endDate = entry.endDate, + recurrent = entry.recurrent, + recurrentMonths = entry.recurrentMonths?.map { m -> Month.of(m) }?.toList() ?: emptyList(), + color = entry.color + )) } override fun getAllEntries(): MutableList { - TODO("Not yet implemented") + val income = repo.getAllByType(EEntryType.INCOME) + val outcome = repo.getAllByType(EEntryType.OUTCOME) + val invest = repo.getAllByType(EEntryType.INVESTMENT) + return (income + outcome + invest).toMutableList() + } + + override fun deleteAll(): Boolean { + return repo.deleteAll() + } + + override fun deleteById(id: Long): Boolean { + return repo.deleteById(id) + } + + override fun editEntry(id: Long, entry: EntryDto): Entry { + if (!verifyDto(entry)) { + throw OctopusPlanningException.create(EBusinessException.INVALID_REQUEST, entry, "Entry must be either recurring or fixed.") + } + + return repo.createEntry(Entry( + id = id, + name = entry.name, + type = EEntryType.valueOf(entry.type), + amount = entry.amount, + fixedDate = entry.fixedDate, + startDate = entry.startDate, + endDate = entry.endDate, + recurrent = entry.recurrent, + recurrentMonths = entry.recurrentMonths?.map { m -> Month.of(m) }?.toList() ?: emptyList(), + color = entry.color + )) + } + + private fun verifyDto(entry: EntryDto): Boolean { + val eventIsRecurring = entry.fixedDate == null && + entry.startDate != null && + entry.endDate != null && + entry.recurrent == true && + entry.recurrentMonths?.isNotEmpty() ?: false + val eventIsFixed = entry.fixedDate != null && + entry.startDate == null && + entry.endDate == null && + entry.recurrent != true && + entry.recurrentMonths?.isEmpty() ?: true + return eventIsRecurring || eventIsFixed } } \ No newline at end of file diff --git a/src/main/kotlin/org/octopus/internal/web/controllers/EntryController.kt b/src/main/kotlin/org/octopus/internal/web/controllers/EntryController.kt index 5d36a6e..99fab93 100755 --- a/src/main/kotlin/org/octopus/internal/web/controllers/EntryController.kt +++ b/src/main/kotlin/org/octopus/internal/web/controllers/EntryController.kt @@ -1,23 +1,86 @@ package org.octopus.internal.web.controllers +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid import lombok.AllArgsConstructor import org.octopus.internal.common.models.Entry import org.octopus.internal.services.EntryService +import org.octopus.internal.web.utils.dtos.EntryDto import org.octopus.internal.web.utils.responses.WebResponse +import org.springframework.boot.actuate.autoconfigure.metrics.MetricsProperties.Web import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/entries") @AllArgsConstructor +@Tag(name = "Entries Management", description = "Operations related to entries.") class EntryController( val entryService: EntryService ) { + @Operation( + summary = "Create a new planning entry", + description = "Adds a new income, outcome, or investment entry to the system.", + responses = [ + ApiResponse(responseCode = "200", description = "Entry created successfully. Returned with ID") + ] + ) + @PostMapping + fun createEntry( + @Valid @RequestBody entryDto: EntryDto + ): WebResponse { + return WebResponse.ok(entryService.createEntry(entryDto)) + } + @Operation( + summary = "Get all entries in the DB", + description = "Returns a list containing all entries.", + responses = [ + ApiResponse(responseCode = "200", description = "A valid list.") + ] + ) @GetMapping - fun getAllClients(): WebResponse> { + fun getAllEntries(): WebResponse> { return WebResponse.ok(entryService.getAllEntries()) } + @Operation( + summary = "Delete all entries in the DB", + description = "Returns the result of the operation.", + responses = [ + ApiResponse(responseCode = "200", description = "Whether the operation succeeded.") + ] + ) + @DeleteMapping + fun deleteAllEntries(): WebResponse { + return WebResponse.ok(entryService.deleteAll()) + } + + @Operation( + summary = "Delete a specific entry in the DB", + description = "Returns the result of the operation.", + responses = [ + ApiResponse(responseCode = "200", description = "Whether the operation succeeded.") + ] + ) + @DeleteMapping("/{id}") + fun deleteEntryById(@PathVariable("id") id: Long): WebResponse { + return WebResponse.ok(entryService.deleteById(id)) + } + + @Operation( + summary = "Updates a specific entry in the DB", + description = "Returns the updated entry.", + responses = [ + ApiResponse(responseCode = "200", description = "The updated entry.") + ] + ) + @PutMapping("/{id}") + fun editEntryById(@PathVariable("id") id: Long, + @Valid @RequestBody entryDto: EntryDto): WebResponse { + return WebResponse.ok(entryService.editEntry(id, entryDto)) + } // @GetMapping("/{id}") // fun getClient(@PathVariable("id") id: String, @RequestParam( diff --git a/src/main/kotlin/org/octopus/internal/web/utils/BaseAdvice.kt b/src/main/kotlin/org/octopus/internal/web/utils/BaseAdvice.kt index 19d6228..74a73ab 100755 --- a/src/main/kotlin/org/octopus/internal/web/utils/BaseAdvice.kt +++ b/src/main/kotlin/org/octopus/internal/web/utils/BaseAdvice.kt @@ -95,8 +95,7 @@ class BaseAdvice { ): WebResponse { return WebResponse.ko( HttpStatus.NOT_ACCEPTABLE, - "${HttpStatus.NOT_ACCEPTABLE.reasonPhrase}: JSON parse error" - + "${HttpStatus.NOT_ACCEPTABLE.reasonPhrase}: JSON parse error. Please verify the body has the correct format.", ) } diff --git a/src/main/kotlin/org/octopus/internal/web/utils/dtos/EntryDto.kt b/src/main/kotlin/org/octopus/internal/web/utils/dtos/EntryDto.kt index 198b3ed..9e356fa 100755 --- a/src/main/kotlin/org/octopus/internal/web/utils/dtos/EntryDto.kt +++ b/src/main/kotlin/org/octopus/internal/web/utils/dtos/EntryDto.kt @@ -1,17 +1,43 @@ package org.octopus.internal.web.utils.dtos +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.DecimalMin +import org.hibernate.validator.constraints.Length +import org.octopus.internal.web.utils.dtos.validators.EntryHexColorValidator import org.octopus.internal.web.utils.dtos.validators.EntryMonthValidator import org.octopus.internal.web.utils.dtos.validators.EntryTypeValidator import java.util.Date data class EntryDto( + @field:Schema(description = "The name of the entry (between 1 and 50 characters long).") + @field:Length(min = 1, max = 50, message = "The name must be between 1 and 50 characters long.") + val name: String, + + @field:Schema(description = "The type of entry (INCOME, OUTCOME, INVESTMENT).") @field:EntryTypeValidator.Validate val type: String, + + @field:Schema(description = "The amount of the entry (greater than 0.00).") + @field:DecimalMin(value="0.00", inclusive = false, message = "The amount must be greater than 0.00.") val amount: Double, + + @field:Schema(description = "IF NOT recurrent: when is the entry being counted.") val fixedDate: Date?, + + @field:Schema(description = "IF recurrent: must be true") val recurrent: Boolean?, + + @field:Schema(description = "IF recurrent: list of month in which the event is repeated (1=JAN, 12=DEC)") @field:EntryMonthValidator.Validate val recurrentMonths: List?, - val endDate: Date? + @field:Schema(description = "IF recurrent: starting date of the repetition") + val startDate: Date?, + + @field:Schema(description = "IF recurrent: ending date of the repetition") + val endDate: Date?, + + @field:Schema(description = "Color of the entry in the UI") + @field:EntryHexColorValidator.Validate + val color: String = "0xFFFFFF" ) \ No newline at end of file diff --git a/src/main/kotlin/org/octopus/internal/web/utils/dtos/validators/EntryHexColorValidator.kt b/src/main/kotlin/org/octopus/internal/web/utils/dtos/validators/EntryHexColorValidator.kt new file mode 100755 index 0000000..ecca425 --- /dev/null +++ b/src/main/kotlin/org/octopus/internal/web/utils/dtos/validators/EntryHexColorValidator.kt @@ -0,0 +1,41 @@ +package org.octopus.internal.web.utils.dtos.validators + +import jakarta.validation.Constraint +import jakarta.validation.ConstraintValidator +import jakarta.validation.ConstraintValidatorContext +import jakarta.validation.Payload +import org.octopus.internal.common.enums.EEntryType +import kotlin.reflect.KClass + + +class EntryHexColorValidator : ConstraintValidator { + companion object { + private val HEX_COLOR_PATTERN = Regex("^0x([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$") + } + + override fun isValid(value: String?, context: ConstraintValidatorContext): Boolean { + if (value.isNullOrBlank()) { + return false + } + + val isMatched = HEX_COLOR_PATTERN.matches(value) + + if (!isMatched) { + context.disableDefaultConstraintViolation() + context.buildConstraintViolationWithTemplate("Invalid hex color format. Value must be in 0xRRGGBB format.") + .addConstraintViolation() + } + + return isMatched + } + + @Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY, AnnotationTarget.VALUE_PARAMETER) + @Retention(AnnotationRetention.RUNTIME) + @MustBeDocumented + @Constraint(validatedBy = [EntryHexColorValidator::class]) + annotation class Validate( + val message: String = "", + val groups: Array> = [], + val payload: Array> = [] + ) +} \ No newline at end of file