Merge pull request 'feature/#25-dtos' (#28) from feature/#25-dtos into develop
All checks were successful
continuous-integration/drone Build is passing

Reviewed-on: https://gitea.fyloz.dev/ColorRecipesExplorer/Backend/pulls/28
This commit is contained in:
William Nolin 2022-04-20 22:42:39 -04:00
commit 5ebaa1d837
142 changed files with 5721 additions and 6611 deletions

View File

@ -2,17 +2,17 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
group = "dev.fyloz.colorrecipesexplorer"
val kotlinVersion = "1.6.0"
val kotlinVersion = "1.6.20"
val springBootVersion = "2.6.1"
plugins {
// Outer scope variables can't be accessed in the plugins section, so we have to redefine them here
val kotlinVersion = "1.6.0"
val kotlinVersion = "1.6.20"
val springBootVersion = "2.6.1"
id("java")
id("org.jetbrains.kotlin.jvm") version kotlinVersion
id("org.jetbrains.dokka") version "1.4.32"
id("org.jetbrains.dokka") version "1.6.10"
id("org.springframework.boot") version springBootVersion
id("org.jetbrains.kotlin.plugin.spring") version kotlinVersion
id("org.jetbrains.kotlin.plugin.jpa") version kotlinVersion
@ -30,7 +30,7 @@ dependencies {
implementation(platform("org.jetbrains.kotlin:kotlin-bom:${kotlinVersion}"))
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1")
implementation("dev.fyloz.colorrecipesexplorer:database-manager:5.2.1")
implementation("dev.fyloz.colorrecipesexplorer:database-manager:6.2")
implementation("dev.fyloz:memorycache:1.0")
implementation("io.github.microutils:kotlin-logging-jvm:2.1.21")
implementation("io.jsonwebtoken:jjwt-api:0.11.2")

View File

@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@ -1,8 +1,9 @@
package dev.fyloz.colorrecipesexplorer.service.files;
package dev.fyloz.colorrecipesexplorer.logic.files;
import dev.fyloz.colorrecipesexplorer.model.Recipe;
import dev.fyloz.colorrecipesexplorer.service.RecipeService;
import dev.fyloz.colorrecipesexplorer.dtos.RecipeDto;
import dev.fyloz.colorrecipesexplorer.logic.RecipeLogic;
import dev.fyloz.colorrecipesexplorer.xlsx.XlsxExporter;
import mu.KotlinLogging;
import org.slf4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Profile;
@ -17,14 +18,12 @@ import java.util.zip.ZipOutputStream;
@Service
@Profile("!emergency")
public class XlsService {
private final RecipeService recipeService;
private final Logger logger;
private final RecipeLogic recipeService;
private final Logger logger = KotlinLogging.INSTANCE.logger("XlsService");
@Autowired
public XlsService(RecipeService recipeService, Logger logger) {
this.recipeService = recipeService;
this.logger = logger;
public XlsService(RecipeLogic recipeLogic) {
this.recipeService = recipeLogic;
}
/**
@ -33,7 +32,7 @@ public class XlsService {
* @param recipe La recette
* @return Le fichier XLS de la recette
*/
public byte[] generate(Recipe recipe) {
public byte[] generate(RecipeDto recipe) {
return new XlsxExporter(logger).generate(recipe);
}
@ -56,10 +55,10 @@ public class XlsService {
logger.info("Exportation de toutes les couleurs en XLS");
byte[] zipContent;
Collection<Recipe> recipes = recipeService.getAll();
Collection<RecipeDto> recipes = recipeService.getAll();
try (ByteArrayOutputStream byteOutput = new ByteArrayOutputStream(); ZipOutputStream zipOutput = new ZipOutputStream(byteOutput)) {
for (Recipe recipe : recipes) {
for (RecipeDto recipe : recipes) {
byte[] recipeXLS = generate(recipe);
zipOutput.putNextEntry(new ZipEntry(String.format("%s_%s.xlsx", recipe.getCompany().getName(), recipe.getName())));
zipOutput.write(recipeXLS, 0, recipeXLS.length);

View File

@ -1,8 +1,8 @@
package dev.fyloz.colorrecipesexplorer.xlsx;
import dev.fyloz.colorrecipesexplorer.model.Mix;
import dev.fyloz.colorrecipesexplorer.model.MixMaterial;
import dev.fyloz.colorrecipesexplorer.model.Recipe;
import dev.fyloz.colorrecipesexplorer.dtos.MixDto;
import dev.fyloz.colorrecipesexplorer.dtos.MixQuantityOutputDto;
import dev.fyloz.colorrecipesexplorer.dtos.RecipeDto;
import dev.fyloz.colorrecipesexplorer.xlsx.component.Document;
import dev.fyloz.colorrecipesexplorer.xlsx.component.Sheet;
import dev.fyloz.colorrecipesexplorer.xlsx.component.Table;
@ -23,7 +23,7 @@ public class XlsxExporter {
this.logger = logger;
}
public byte[] generate(Recipe recipe) {
public byte[] generate(RecipeDto recipe) {
logger.info(String.format("Génération du XLS de la couleur %s (%s)", recipe.getName(), recipe.getId()));
Document document = new Document(recipe.getName(), logger);
@ -44,7 +44,7 @@ public class XlsxExporter {
return output;
}
private void registerCells(Recipe recipe, Sheet sheet) {
private void registerCells(RecipeDto recipe, Sheet sheet) {
// Header
sheet.registerCell(new TitleCell(recipe.getName()));
sheet.registerCell(new DescriptionCell(DescriptionCell.DescriptionCellType.NAME, "Bannière"));
@ -59,20 +59,20 @@ public class XlsxExporter {
sheet.registerCell(new DescriptionCell(DescriptionCell.DescriptionCellType.VALUE_STR, recipe.getRemark()));
// Mélanges
Collection<Mix> recipeMixes = recipe.getMixes();
Collection<MixDto> recipeMixes = recipe.getMixes();
if (recipeMixes.size() > 0) {
sheet.registerCell(new SectionTitleCell("Recette"));
for (Mix mix : recipeMixes) {
Table mixTable = new Table(4, mix.getMixMaterials().size() + 1, mix.getMixType().getName());
for (MixDto mix : recipeMixes) {
Table mixTable = new Table(4, mix.getMixQuantities().getAll().size() + 1, mix.getMixType().getName());
mixTable.setColumnName(0, "Quantité");
mixTable.setColumnName(2, "Unités");
int row = 0;
for (MixMaterial mixMaterial : mix.getMixMaterials()) {
mixTable.setRowName(row, mixMaterial.getMaterial().getName());
mixTable.setContent(new Position(1, row + 1), mixMaterial.getQuantity());
mixTable.setContent(new Position(3, row + 1), mixMaterial.getMaterial().getMaterialType().getUsePercentages() ? "%" : "mL");
for (MixQuantityOutputDto mixQuantity : mix.getMixQuantitiesOutput()) {
mixTable.setRowName(row, mixQuantity.getMaterial().getName());
mixTable.setContent(new Position(1, row + 1), mixQuantity.getQuantity());
mixTable.setContent(new Position(3, row + 1), mixQuantity.getMaterial().getMaterialType().getUsePercentages() ? "%" : "mL");
row++;
}

View File

@ -0,0 +1,50 @@
package dev.fyloz.colorrecipesexplorer
object Constants {
object ControllerPaths {
const val COMPANY = "/api/company"
const val FILE = "/api/file"
const val GROUP = "/api/user/group"
const val INVENTORY = "/api/inventory"
const val MATERIAL = "/api/material"
const val MATERIAL_TYPE = "/api/materialtype"
const val MIX = "/api/recipe/mix"
const val RECIPE = "/api/recipe"
const val TOUCH_UP_KIT = "/api/touchupkit"
const val USER = "/api/user"
}
object FilePaths {
private const val PDF = "pdf"
private const val IMAGES = "images"
const val SIMDUT = "$PDF/simdut"
const val TOUCH_UP_KITS = "$PDF/touchupkits"
const val RECIPE_IMAGES = "$IMAGES/recipes"
}
object ModelNames {
const val COMPANY = "Company"
const val GROUP = "Group"
const val MATERIAL = "Material"
const val MATERIAL_TYPE = "MaterialType"
const val MIX = "Mix"
const val MIX_MATERIAL = "MixMaterial"
const val MIX_TYPE = "MixType"
const val RECIPE = "Recipe"
const val RECIPE_STEP = "RecipeStep"
const val TOUCH_UP_KIT = "TouchUpKit"
const val USER = "User"
}
object ValidationMessages {
const val SIZE_GREATER_OR_EQUALS_ZERO = "Must be greater or equals to 0"
const val SIZE_GREATER_OR_EQUALS_ONE = "Must be greater or equals to 1"
const val RANGE_OUTSIDE_PERCENTS = "Must be between 0 and 100"
const val PASSWORD_TOO_SMALL = "Must contains at least 8 characters"
}
object ValidationRegexes {
const val VALIDATION_COLOR_PATTERN = "^#([0-9a-f]{6})$"
}
}

View File

@ -3,9 +3,9 @@ package dev.fyloz.colorrecipesexplorer
import dev.fyloz.colorrecipesexplorer.databasemanager.CreDatabase
import dev.fyloz.colorrecipesexplorer.databasemanager.databaseContext
import dev.fyloz.colorrecipesexplorer.databasemanager.databaseUpdaterProperties
import dev.fyloz.colorrecipesexplorer.model.Configuration
import dev.fyloz.colorrecipesexplorer.logic.config.ConfigurationLogic
import dev.fyloz.colorrecipesexplorer.model.ConfigurationType
import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService
import mu.KotlinLogging
import org.slf4j.Logger
import org.springframework.boot.jdbc.DataSourceBuilder
import org.springframework.context.annotation.Bean
@ -15,7 +15,7 @@ import org.springframework.core.env.ConfigurableEnvironment
import javax.sql.DataSource
import org.springframework.context.annotation.Configuration as SpringConfiguration
const val SUPPORTED_DATABASE_VERSION = 5
const val SUPPORTED_DATABASE_VERSION = 6
const val ENV_VAR_ENABLE_DATABASE_UPDATE_NAME = "CRE_ENABLE_DB_UPDATE"
val DATABASE_NAME_REGEX = Regex("(\\w+)$")
@ -23,11 +23,12 @@ val DATABASE_NAME_REGEX = Regex("(\\w+)$")
@SpringConfiguration
@DependsOn("configurationsInitializer", "configurationService")
class DataSourceConfiguration {
private val logger = KotlinLogging.logger {}
@Bean(name = ["dataSource"])
fun customDataSource(
logger: Logger,
environment: ConfigurableEnvironment,
configurationService: ConfigurationService
configurationService: ConfigurationLogic
): DataSource {
fun getConfiguration(type: ConfigurationType) =
if (type.secure) configurationService.getSecure(type)

View File

@ -4,10 +4,10 @@ import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase
import dev.fyloz.colorrecipesexplorer.config.initializers.AbstractInitializer
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
import dev.fyloz.colorrecipesexplorer.emergencyMode
import dev.fyloz.colorrecipesexplorer.logic.config.ConfigurationLogic
import dev.fyloz.colorrecipesexplorer.rest.CRE_PROPERTIES
import dev.fyloz.colorrecipesexplorer.restartApplication
import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService
import org.slf4j.Logger
import mu.KotlinLogging
import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent
import org.springframework.context.ApplicationListener
import org.springframework.context.annotation.Configuration
@ -20,10 +20,11 @@ import kotlin.concurrent.thread
@Order(Ordered.HIGHEST_PRECEDENCE)
@RequireDatabase
class ApplicationReadyListener(
private val configurationService: ConfigurationService,
private val creProperties: CreProperties,
private val logger: Logger
private val configurationLogic: ConfigurationLogic,
private val creProperties: CreProperties
) : AbstractInitializer() {
private val logger = KotlinLogging.logger {}
override fun initialize() {
if (emergencyMode) {
logger.error("Emergency mode is enabled, default material types will not be created")
@ -40,17 +41,17 @@ class ApplicationReadyListener(
}
private fun initDatabaseConfigurations() {
configurationService.initializeProperties { !it.file }
configurationLogic.initializeProperties { !it.file }
}
}
@Configuration("configurationsInitializer")
class ConfigurationsInitializer(
private val configurationService: ConfigurationService
private val configurationLogic: ConfigurationLogic
) {
@PostConstruct
fun initializeFileConfigurations() {
configurationService.initializeProperties { it.file }
configurationLogic.initializeProperties { it.file }
}
}

View File

@ -2,7 +2,7 @@ package dev.fyloz.colorrecipesexplorer.config
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
import dev.fyloz.colorrecipesexplorer.config.properties.MaterialTypeProperties
import dev.fyloz.colorrecipesexplorer.service.files.CachedFileSystemItem
import dev.fyloz.colorrecipesexplorer.logic.files.CachedFileSystemItem
import dev.fyloz.memorycache.ExpiringMemoryCache
import dev.fyloz.memorycache.MemoryCache
import org.springframework.boot.context.properties.EnableConfigurationProperties
@ -13,6 +13,6 @@ import org.springframework.context.annotation.Configuration
@EnableConfigurationProperties(MaterialTypeProperties::class, CreProperties::class)
class CreConfiguration(private val creProperties: CreProperties) {
@Bean
fun fileCache(): MemoryCache<String, CachedFileSystemItem> =
fun fileMemoryCache(): MemoryCache<String, CachedFileSystemItem> =
ExpiringMemoryCache(maxAccessCount = creProperties.fileCacheMaxAccessCount)
}

View File

@ -0,0 +1,15 @@
package dev.fyloz.colorrecipesexplorer.config.annotations
import org.springframework.stereotype.Service
@Service
@RequireDatabase
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class ServiceComponent
@Service
@RequireDatabase
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class LogicComponent

View File

@ -2,16 +2,15 @@ package dev.fyloz.colorrecipesexplorer.config.initializers
import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase
import dev.fyloz.colorrecipesexplorer.config.properties.MaterialTypeProperties
import dev.fyloz.colorrecipesexplorer.model.MaterialType
import dev.fyloz.colorrecipesexplorer.model.materialType
import dev.fyloz.colorrecipesexplorer.service.MaterialTypeService
import dev.fyloz.colorrecipesexplorer.dtos.MaterialTypeDto
import dev.fyloz.colorrecipesexplorer.logic.MaterialTypeLogic
import mu.KotlinLogging
import org.springframework.context.annotation.Configuration
@Configuration
@RequireDatabase
class MaterialTypeInitializer(
private val materialTypeService: MaterialTypeService,
private val materialTypeLogic: MaterialTypeLogic,
private val materialTypeProperties: MaterialTypeProperties
) : AbstractInitializer() {
private val logger = KotlinLogging.logger {}
@ -24,21 +23,20 @@ class MaterialTypeInitializer(
private fun ensureSystemMaterialTypesExists() {
val systemTypes = materialTypeProperties.systemTypes.map { it.toMaterialType() }
val oldSystemTypes = materialTypeService.getAllSystemTypes().toMutableSet()
val oldSystemTypes = materialTypeLogic.getAll(true).toMutableSet()
fun saveOrUpdateSystemType(type: MaterialType) {
if (materialTypeService.existsByName(type.name)) {
with(materialTypeService.getByName(type.name)) {
if (!this.systemType) {
logger.info("Material type '${type.name}' already exists and will be flagged as a system type")
materialTypeService.update(this.copy(systemType = true))
} else {
logger.debug("System material type '${type.name}' already exists")
}
fun saveOrUpdateSystemType(type: MaterialTypeDto) {
val storedMaterialType = materialTypeLogic.getByName(type.name)
if (storedMaterialType != null) {
if (!storedMaterialType.systemType) {
logger.info("Material type '${type.name}' already exists and will be flagged as a system type")
materialTypeLogic.update(storedMaterialType.copy(systemType = true))
} else {
logger.debug("System material type '${type.name}' already exists")
}
} else {
logger.info("System material type '${type.name}' will be created")
materialTypeService.save(type)
materialTypeLogic.save(type)
}
}
@ -51,7 +49,7 @@ class MaterialTypeInitializer(
// Remove old system types
oldSystemTypes.forEach {
logger.info("Material type '${it.name}' is not a system type anymore")
materialTypeService.updateSystemType(it.copy(systemType = false))
materialTypeLogic.updateNonSystemType(it.copy(systemType = false))
}
}
}

View File

@ -1,9 +1,8 @@
package dev.fyloz.colorrecipesexplorer.config.initializers
import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase
import dev.fyloz.colorrecipesexplorer.model.Mix
import dev.fyloz.colorrecipesexplorer.model.MixMaterial
import dev.fyloz.colorrecipesexplorer.service.MixService
import dev.fyloz.colorrecipesexplorer.dtos.*
import dev.fyloz.colorrecipesexplorer.logic.MixLogic
import dev.fyloz.colorrecipesexplorer.utils.merge
import mu.KotlinLogging
import org.springframework.context.annotation.Configuration
@ -12,7 +11,7 @@ import java.util.*
@Configuration
@RequireDatabase
class MixInitializer(
private val mixService: MixService
private val mixLogic: MixLogic
) : AbstractInitializer() {
private val logger = KotlinLogging.logger {}
@ -24,19 +23,20 @@ class MixInitializer(
private fun fixAllPositions() {
logger.debug("Validating mix materials positions...")
mixService.getAll()
.filter { mix -> mix.mixMaterials.any { it.position == 0 } }
mixLogic.getAll()
.filter { it.mixQuantities.all.any { mq -> mq.position == 0 } }
.forEach(this::fixMixPositions)
logger.debug("Mix materials positions are valid!")
}
private fun fixMixPositions(mix: Mix) {
val maxPosition = mix.mixMaterials.maxOf { it.position }
private fun fixMixPositions(mix: MixDto) {
val mixQuantities = mix.mixQuantitiesOutput
val maxPosition = mixQuantities.maxOf { it.position }
logger.warn("Mix ${mix.id} (${mix.mixType.name}, ${mix.recipe.name}) has invalid positions:")
logger.warn("Mix ${mix.id} (mix name: ${mix.mixType.name}, recipe id: ${mix.recipeId}) has invalid positions:")
val invalidMixMaterials: Collection<MixMaterial> = with(mix.mixMaterials.filter { it.position == 0 }) {
val invalidMixQuantities: Collection<MixQuantityOutputDto> = with(mixQuantities.filter { it.position == 0 }) {
if (maxPosition == 0 && this.size > 1) {
orderMixMaterials(this)
} else {
@ -44,28 +44,37 @@ class MixInitializer(
}
}
val fixedMixMaterials = increaseMixMaterialsPosition(invalidMixMaterials, maxPosition + 1)
val updatedMixMaterials = mix.mixMaterials.merge(fixedMixMaterials)
val fixedMixQuantities = increaseMixMaterialsPosition(invalidMixQuantities, maxPosition + 1)
val updatedMixQuantities =
mixQuantities.map { MixQuantitySaveDto(it.id, it.material.id, it.quantity, it.position, it.isMixType) }
.merge(fixedMixQuantities)
with(mix.copy(mixMaterials = updatedMixMaterials.toMutableSet())) {
mixService.update(this)
}
val updatedMix = MixSaveDto(mix.id, mix.mixType.name, mix.recipeId, mix.mixType.materialType.id, updatedMixQuantities)
mixLogic.update(updatedMix)
}
private fun increaseMixMaterialsPosition(mixMaterials: Iterable<MixMaterial>, firstPosition: Int) =
mixMaterials
.mapIndexed { index, mixMaterial -> mixMaterial.copy(position = firstPosition + index) }
private fun increaseMixMaterialsPosition(mixQuantities: Iterable<MixQuantityOutputDto>, firstPosition: Int) =
mixQuantities
.mapIndexed { index, mixQuantity ->
MixQuantitySaveDto(
mixQuantity.id,
mixQuantity.material.id,
mixQuantity.quantity,
firstPosition + index,
mixQuantity.isMixType
)
}
.onEach {
logger.info("\tPosition of material ${it.material.id} (${it.material.name}) has been set to ${it.position}")
logger.info("\tPosition of material ${it.id} (mixType: ${it.isMixType}) has been set to ${it.position}")
}
private fun orderMixMaterials(mixMaterials: Collection<MixMaterial>) =
LinkedList(mixMaterials).apply {
while (this.peek().material.materialType?.usePercentages == true) {
private fun orderMixMaterials(mixQuantities: Collection<MixQuantityOutputDto>) =
LinkedList(mixQuantities).apply {
while (this.peek().material.materialType.usePercentages) {
// The first mix material can't use percents, so move it to the end of the queue
val pop = this.pop()
this.add(pop)
logger.debug("\tMaterial ${pop.material.id} (${pop.material.name}) uses percents, moving to the end of the queue")
logger.debug("\tMaterial ${pop.id} (mixType: ${pop.isMixType}) uses percents, moving to the end of the queue")
}
}
}

View File

@ -1,10 +1,10 @@
package dev.fyloz.colorrecipesexplorer.config.initializers
import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase
import dev.fyloz.colorrecipesexplorer.model.Recipe
import dev.fyloz.colorrecipesexplorer.model.RecipeGroupInformation
import dev.fyloz.colorrecipesexplorer.model.RecipeStep
import dev.fyloz.colorrecipesexplorer.service.RecipeService
import dev.fyloz.colorrecipesexplorer.dtos.RecipeDto
import dev.fyloz.colorrecipesexplorer.dtos.RecipeGroupInformationDto
import dev.fyloz.colorrecipesexplorer.dtos.RecipeStepDto
import dev.fyloz.colorrecipesexplorer.logic.RecipeLogic
import dev.fyloz.colorrecipesexplorer.utils.merge
import mu.KotlinLogging
import org.springframework.context.annotation.Configuration
@ -12,7 +12,7 @@ import org.springframework.context.annotation.Configuration
@Configuration
@RequireDatabase
class RecipeInitializer(
private val recipeService: RecipeService
private val recipeLogic: RecipeLogic
) : AbstractInitializer() {
private val logger = KotlinLogging.logger {}
@ -24,44 +24,43 @@ class RecipeInitializer(
private fun fixAllPositions() {
logger.debug("Validating recipes steps positions...")
recipeService.getAll()
recipeLogic.getAllWithMixesAndGroupsInformation()
.forEach(this::fixRecipePositions)
logger.debug("Recipes steps positions are valid!")
}
private fun fixRecipePositions(recipe: Recipe) {
private fun fixRecipePositions(recipe: RecipeDto) {
val fixedGroupInformation = recipe.groupsInformation
.filter { it.steps != null }
.filter { groupInfo -> groupInfo.steps!!.any { it.position == 0 } }
.filter { groupInfo -> groupInfo.steps.any { it.position == 0 } }
.map { fixGroupInformationPositions(recipe, it) }
val updatedGroupInformation = recipe.groupsInformation.merge(fixedGroupInformation)
val updatedGroupInformation = recipe.groupsInformation.merge(fixedGroupInformation) { it.id }
with(recipe.copy(groupsInformation = updatedGroupInformation.toMutableSet())) {
recipeService.update(this)
with(recipe.copy(groupsInformation = updatedGroupInformation)) {
recipeLogic.update(this)
}
}
private fun fixGroupInformationPositions(
recipe: Recipe,
groupInformation: RecipeGroupInformation
): RecipeGroupInformation {
val steps = groupInformation.steps!!
recipe: RecipeDto,
groupInformation: RecipeGroupInformationDto
): RecipeGroupInformationDto {
val steps = groupInformation.steps
val maxPosition = steps.maxOf { it.position }
logger.warn("Recipe ${recipe.id} (${recipe.name}) has invalid positions:")
val invalidRecipeSteps = steps.filter { it.position == 0 }
val fixedRecipeSteps = increaseRecipeStepsPosition(groupInformation, invalidRecipeSteps, maxPosition + 1)
val updatedRecipeSteps = steps.merge(fixedRecipeSteps)
val updatedRecipeSteps = steps.merge(fixedRecipeSteps) { it.id }
return groupInformation.copy(steps = updatedRecipeSteps.toMutableSet())
return groupInformation.copy(steps = updatedRecipeSteps)
}
private fun increaseRecipeStepsPosition(
groupInformation: RecipeGroupInformation,
recipeSteps: Iterable<RecipeStep>,
groupInformation: RecipeGroupInformationDto,
recipeSteps: Iterable<RecipeStepDto>,
firstPosition: Int
) =
recipeSteps

View File

@ -1,7 +1,6 @@
package dev.fyloz.colorrecipesexplorer.config.properties
import dev.fyloz.colorrecipesexplorer.model.MaterialType
import dev.fyloz.colorrecipesexplorer.model.materialType
import dev.fyloz.colorrecipesexplorer.dtos.MaterialTypeDto
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.stereotype.Component
import org.springframework.util.Assert
@ -16,9 +15,9 @@ class MaterialTypeProperties {
var prefix: String = "",
var usePercentages: Boolean = false
) {
fun toMaterialType(): MaterialType {
fun toMaterialType(): MaterialTypeDto {
Assert.hasText(name, "A system material type has an empty name")
return materialType(name = name, prefix = prefix, usePercentages = usePercentages, systemType = true)
return MaterialTypeDto(name = name, prefix = prefix, usePercentages = usePercentages, systemType = true)
}
}
}

View File

@ -2,13 +2,12 @@ package dev.fyloz.colorrecipesexplorer.config.security
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
import dev.fyloz.colorrecipesexplorer.dtos.UserDetails
import dev.fyloz.colorrecipesexplorer.dtos.UserDto
import dev.fyloz.colorrecipesexplorer.dtos.UserLoginRequestDto
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import dev.fyloz.colorrecipesexplorer.model.account.UserDetails
import dev.fyloz.colorrecipesexplorer.model.account.UserLoginRequest
import dev.fyloz.colorrecipesexplorer.model.account.UserOutputDto
import dev.fyloz.colorrecipesexplorer.model.account.toAuthorities
import dev.fyloz.colorrecipesexplorer.service.users.JwtService
import dev.fyloz.colorrecipesexplorer.service.users.UserDetailsService
import dev.fyloz.colorrecipesexplorer.logic.users.JwtLogic
import dev.fyloz.colorrecipesexplorer.logic.users.UserDetailsLogic
import dev.fyloz.colorrecipesexplorer.utils.addCookie
import io.jsonwebtoken.ExpiredJwtException
import org.springframework.security.authentication.AuthenticationManager
@ -28,7 +27,7 @@ val blacklistedJwtTokens = mutableListOf<String>() // Not working, move to a ca
class JwtAuthenticationFilter(
private val authManager: AuthenticationManager,
private val jwtService: JwtService,
private val jwtLogic: JwtLogic,
private val securityProperties: CreSecurityProperties,
private val updateUserLoginTime: (Long) -> Unit
) : UsernamePasswordAuthenticationFilter() {
@ -40,7 +39,7 @@ class JwtAuthenticationFilter(
}
override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication {
val loginRequest = jacksonObjectMapper().readValue(request.inputStream, UserLoginRequest::class.java)
val loginRequest = jacksonObjectMapper().readValue(request.inputStream, UserLoginRequestDto::class.java)
logger.debug("Login attempt for user ${loginRequest.id}...")
return authManager.authenticate(UsernamePasswordAuthenticationToken(loginRequest.id, loginRequest.password))
}
@ -52,7 +51,7 @@ class JwtAuthenticationFilter(
auth: Authentication
) {
val userDetails = auth.principal as UserDetails
val token = jwtService.buildJwt(userDetails)
val token = jwtLogic.buildJwt(userDetails)
with(userDetails.user) {
logger.info("User ${this.id} (${this.firstName} ${this.lastName}) has logged in successfully")
@ -72,9 +71,9 @@ class JwtAuthenticationFilter(
}
class JwtAuthorizationFilter(
private val jwtService: JwtService,
private val jwtLogic: JwtLogic,
authenticationManager: AuthenticationManager,
private val userDetailsService: UserDetailsService
private val userDetailsLogic: UserDetailsLogic
) : BasicAuthenticationFilter(authenticationManager) {
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
fun tryLoginFromBearer(): Boolean {
@ -109,18 +108,18 @@ class JwtAuthorizationFilter(
private fun getAuthentication(token: String): UsernamePasswordAuthenticationToken? {
return try {
val user = jwtService.parseJwt(token.replace("Bearer", ""))
val user = jwtLogic.parseJwt(token.replace("Bearer", ""))
getAuthenticationToken(user)
} catch (_: ExpiredJwtException) {
null
}
}
private fun getAuthenticationToken(user: UserOutputDto) =
UsernamePasswordAuthenticationToken(user.id, null, user.permissions.toAuthorities())
private fun getAuthenticationToken(user: UserDto) =
UsernamePasswordAuthenticationToken(user.id, null, user.authorities)
private fun getAuthenticationToken(userId: Long): UsernamePasswordAuthenticationToken? = try {
val userDetails = userDetailsService.loadUserById(userId)
val userDetails = userDetailsLogic.loadUserById(userId)
UsernamePasswordAuthenticationToken(userDetails.username, null, userDetails.authorities)
} catch (_: NotFoundException) {
null

View File

@ -1,13 +1,12 @@
package dev.fyloz.colorrecipesexplorer.config.security
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
import dev.fyloz.colorrecipesexplorer.dtos.UserDto
import dev.fyloz.colorrecipesexplorer.emergencyMode
import dev.fyloz.colorrecipesexplorer.logic.users.JwtLogic
import dev.fyloz.colorrecipesexplorer.logic.users.UserDetailsLogic
import dev.fyloz.colorrecipesexplorer.logic.users.UserLogic
import dev.fyloz.colorrecipesexplorer.model.account.Permission
import dev.fyloz.colorrecipesexplorer.model.account.User
import dev.fyloz.colorrecipesexplorer.service.users.JwtService
import dev.fyloz.colorrecipesexplorer.service.users.UserDetailsService
import dev.fyloz.colorrecipesexplorer.service.users.UserService
import mu.KLogger
import mu.KotlinLogging
import org.slf4j.Logger
import org.springframework.boot.context.properties.EnableConfigurationProperties
@ -38,8 +37,8 @@ private const val rootUserFirstName = "Root"
private const val rootUserLastName = "User"
abstract class BaseSecurityConfig(
private val userDetailsService: UserDetailsService,
private val jwtService: JwtService,
private val userDetailsLogic: UserDetailsLogic,
private val jwtLogic: JwtLogic,
private val environment: Environment,
protected val securityProperties: CreSecurityProperties
) : WebSecurityConfigurerAdapter() {
@ -70,7 +69,7 @@ abstract class BaseSecurityConfig(
}
override fun configure(authBuilder: AuthenticationManagerBuilder) {
authBuilder.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder)
authBuilder.userDetailsService(userDetailsLogic).passwordEncoder(passwordEncoder)
}
override fun configure(http: HttpSecurity) {
@ -81,13 +80,13 @@ abstract class BaseSecurityConfig(
.addFilter(
JwtAuthenticationFilter(
authenticationManager(),
jwtService,
jwtLogic,
securityProperties,
this::updateUserLoginTime
)
)
.addFilter(
JwtAuthorizationFilter(jwtService, authenticationManager(), userDetailsService)
JwtAuthorizationFilter(jwtLogic, authenticationManager(), userDetailsLogic)
)
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
@ -118,12 +117,12 @@ abstract class BaseSecurityConfig(
@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableConfigurationProperties(CreSecurityProperties::class)
class SecurityConfig(
@Lazy userDetailsService: UserDetailsService,
@Lazy private val userService: UserService,
jwtService: JwtService,
@Lazy userDetailsLogic: UserDetailsLogic,
@Lazy private val userLogic: UserLogic,
jwtLogic: JwtLogic,
environment: Environment,
securityProperties: CreSecurityProperties
) : BaseSecurityConfig(userDetailsService, jwtService, environment, securityProperties) {
) : BaseSecurityConfig(userDetailsLogic, jwtLogic, environment, securityProperties) {
override val logger = KotlinLogging.logger {}
@PostConstruct
@ -137,7 +136,7 @@ class SecurityConfig(
}
override fun updateUserLoginTime(userId: Long) {
userService.updateLastLoginTime(userId)
userLogic.updateLastLoginTime(userId)
}
private fun createRootUser() {
@ -146,15 +145,16 @@ class SecurityConfig(
}
with(securityProperties.root!!) {
if (!userService.existsById(this.id)) {
userService.save(
User(
if (!userLogic.existsById(this.id)) {
userLogic.save(
UserDto(
id = this.id,
firstName = rootUserFirstName,
lastName = rootUserLastName,
group = null,
password = passwordEncoder.encode(this.password),
isSystemUser = true,
permissions = mutableSetOf(Permission.ADMIN)
permissions = listOf(Permission.ADMIN),
isSystemUser = true
)
)
}
@ -166,11 +166,11 @@ class SecurityConfig(
@Profile("emergency")
@EnableConfigurationProperties(CreSecurityProperties::class)
class EmergencySecurityConfig(
userDetailsService: UserDetailsService,
jwtService: JwtService,
userDetailsLogic: UserDetailsLogic,
jwtLogic: JwtLogic,
environment: Environment,
securityProperties: CreSecurityProperties
) : BaseSecurityConfig(userDetailsService, jwtService, environment, securityProperties) {
) : BaseSecurityConfig(userDetailsLogic, jwtLogic, environment, securityProperties) {
override val logger = KotlinLogging.logger {}
init {

View File

@ -0,0 +1,10 @@
package dev.fyloz.colorrecipesexplorer.dtos
import javax.validation.constraints.NotBlank
data class CompanyDto(
override val id: Long = 0L,
@field:NotBlank
val name: String
) : EntityDto

View File

@ -0,0 +1,5 @@
package dev.fyloz.colorrecipesexplorer.dtos
interface EntityDto {
val id: Long
}

View File

@ -0,0 +1,25 @@
package dev.fyloz.colorrecipesexplorer.dtos
import com.fasterxml.jackson.annotation.JsonIgnore
import dev.fyloz.colorrecipesexplorer.model.account.Permission
import javax.validation.constraints.NotBlank
import javax.validation.constraints.NotEmpty
data class GroupDto(
override val id: Long = 0L,
@field:NotBlank
val name: String,
@field:NotEmpty
val permissions: List<Permission>,
val explicitPermissions: List<Permission> = listOf()
) : EntityDto {
@get:JsonIgnore
val defaultGroupUserId = getDefaultGroupUserId(id)
companion object {
fun getDefaultGroupUserId(id: Long) = 1000000 + id
}
}

View File

@ -0,0 +1,41 @@
package dev.fyloz.colorrecipesexplorer.dtos
import dev.fyloz.colorrecipesexplorer.Constants
import org.springframework.web.multipart.MultipartFile
import javax.validation.constraints.Min
import javax.validation.constraints.NotBlank
data class MaterialDto(
override val id: Long = 0L,
val name: String,
val inventoryQuantity: Float,
val isMixType: Boolean,
val materialType: MaterialTypeDto,
val hasSimdut: Boolean = false
) : EntityDto
data class MaterialSaveDto(
override val id: Long = 0L,
@field:NotBlank
val name: String,
@field:Min(0, message = Constants.ValidationMessages.SIZE_GREATER_OR_EQUALS_ZERO)
val inventoryQuantity: Float,
val materialTypeId: Long,
val simdutFile: MultipartFile?
) : EntityDto
data class MaterialQuantityDto(
val materialId: Long,
@field:Min(0, message = Constants.ValidationMessages.SIZE_GREATER_OR_EQUALS_ZERO)
val quantity: Float
)

View File

@ -0,0 +1,13 @@
package dev.fyloz.colorrecipesexplorer.dtos
data class MaterialTypeDto(
override val id: Long = 0L,
val name: String,
val prefix: String,
val usePercentages: Boolean,
val systemType: Boolean = false
) : EntityDto

View File

@ -0,0 +1,77 @@
package dev.fyloz.colorrecipesexplorer.dtos
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonProperty
import dev.fyloz.colorrecipesexplorer.Constants
import javax.validation.constraints.Min
import javax.validation.constraints.NotBlank
data class MixDto(
override val id: Long = 0L,
val location: String? = null,
@JsonIgnore
val recipeId: Long,
val mixType: MixTypeDto,
@JsonIgnore
val mixQuantities: MixQuantitiesDto,
) : EntityDto {
@Suppress("unused")
@get:JsonProperty("mixQuantities")
val mixQuantitiesOutput by lazy {
mixQuantities.materials.map {
MixQuantityOutputDto(it.id, it.material, it.quantity, it.position, false)
} + mixQuantities.mixTypes.map {
MixQuantityOutputDto(it.id, it.mixType.asMaterial(), it.quantity, it.position, true)
}
}
}
data class MixQuantitiesDto(
val materials: List<MixMaterialDto> = listOf(),
val mixTypes: List<MixMixTypeDto> = listOf()
) {
val all get() = materials + mixTypes
}
data class MixQuantityOutputDto(
val id: Long,
val material: MaterialDto,
val quantity: Float,
val position: Int,
val isMixType: Boolean
)
data class MixSaveDto(
val id: Long = 0L,
@field:NotBlank
val name: String,
val recipeId: Long = 0L,
val materialTypeId: Long,
val mixQuantities: List<MixQuantitySaveDto>
)
data class MixDeductDto(
val id: Long,
@field:Min(0, message = Constants.ValidationMessages.SIZE_GREATER_OR_EQUALS_ZERO)
val ratio: Float
)
data class MixLocationDto(
val mixId: Long,
val location: String?
)

View File

@ -0,0 +1,57 @@
package dev.fyloz.colorrecipesexplorer.dtos
import dev.fyloz.colorrecipesexplorer.Constants
import javax.validation.constraints.Min
sealed interface MixQuantityDto : EntityDto {
val quantity: Float
val position: Int
val materialType: MaterialTypeDto
val name: String
}
data class MixMaterialDto(
override val id: Long = 0L,
val material: MaterialDto,
override val quantity: Float,
override val position: Int
) : MixQuantityDto {
override val materialType: MaterialTypeDto
get() = material.materialType
override val name: String
get() = material.name
}
data class MixMixTypeDto(
override val id: Long,
val mixType: MixTypeDto,
override val quantity: Float,
override val position: Int
) : MixQuantityDto {
override val materialType: MaterialTypeDto
get() = mixType.materialType
override val name: String
get() = mixType.name
}
data class MixQuantitySaveDto(
override val id: Long = 0L,
val materialId: Long,
@field:Min(0, message = Constants.ValidationMessages.SIZE_GREATER_OR_EQUALS_ZERO)
val quantity: Float,
val position: Int,
val isMixType: Boolean
) : EntityDto

View File

@ -0,0 +1,14 @@
package dev.fyloz.colorrecipesexplorer.dtos
data class MixTypeDto(
override val id: Long = 0L,
val name: String,
val materialType: MaterialTypeDto,
val material: MaterialDto? = null
) : EntityDto {
fun asMaterial() =
MaterialDto(id, name, 0f, true, materialType)
}

View File

@ -0,0 +1,121 @@
package dev.fyloz.colorrecipesexplorer.dtos
import com.fasterxml.jackson.annotation.JsonIgnore
import dev.fyloz.colorrecipesexplorer.Constants
import java.time.LocalDate
import javax.validation.constraints.Max
import javax.validation.constraints.Min
import javax.validation.constraints.NotBlank
import javax.validation.constraints.Pattern
data class RecipeDto(
override val id: Long = 0L,
val name: String,
val description: String,
val color: String,
val gloss: Byte,
val sample: Int?,
val approbationDate: LocalDate?,
val approbationExpired: Boolean,
val remark: String,
val company: CompanyDto,
val mixes: List<MixDto>,
val groupsInformation: List<RecipeGroupInformationDto>
) : EntityDto {
val mixTypes: Collection<MixTypeDto>
@JsonIgnore
get() = mixes.map { it.mixType }
}
data class RecipeSaveDto(
@field:NotBlank
val name: String,
@field:NotBlank
val description: String,
@field:NotBlank
@field:Pattern(regexp = Constants.ValidationRegexes.VALIDATION_COLOR_PATTERN)
val color: String,
@field:Min(0, message = Constants.ValidationMessages.RANGE_OUTSIDE_PERCENTS)
@field:Max(100, message = Constants.ValidationMessages.RANGE_OUTSIDE_PERCENTS)
val gloss: Byte,
@field:Min(0, message = Constants.ValidationMessages.SIZE_GREATER_OR_EQUALS_ZERO)
val sample: Int?,
val approbationDate: LocalDate?,
val remark: String?,
val companyId: Long
)
data class RecipeUpdateDto(
val id: Long,
@field:NotBlank
val name: String,
@field:NotBlank
val description: String,
@field:NotBlank
@field:Pattern(regexp = Constants.ValidationRegexes.VALIDATION_COLOR_PATTERN)
val color: String,
@field:Min(0, message = Constants.ValidationMessages.RANGE_OUTSIDE_PERCENTS)
@field:Max(100, message = Constants.ValidationMessages.RANGE_OUTSIDE_PERCENTS)
val gloss: Byte,
@field:Min(0, message = Constants.ValidationMessages.SIZE_GREATER_OR_EQUALS_ZERO)
val sample: Int?,
val approbationDate: LocalDate?,
val remark: String?,
val steps: List<RecipeGroupStepsDto>
)
data class RecipeGroupInformationDto(
override val id: Long = 0L,
val group: GroupDto,
val note: String? = null,
val steps: List<RecipeStepDto> = listOf()
) : EntityDto
data class RecipeGroupStepsDto(
val groupId: Long,
val steps: List<RecipeStepDto>
)
data class RecipeGroupNoteDto(
val groupId: Long,
val content: String?
)
data class RecipePublicDataDto(
val recipeId: Long,
val notes: List<RecipeGroupNoteDto>,
val mixesLocation: List<MixLocationDto>
)

View File

@ -0,0 +1,9 @@
package dev.fyloz.colorrecipesexplorer.dtos
data class RecipeStepDto(
override val id: Long = 0L,
val position: Int,
val message: String
) : EntityDto

View File

@ -0,0 +1,52 @@
package dev.fyloz.colorrecipesexplorer.dtos
import dev.fyloz.colorrecipesexplorer.Constants
import java.time.LocalDate
import javax.validation.constraints.Min
import javax.validation.constraints.NotBlank
import javax.validation.constraints.NotEmpty
data class TouchUpKitDto(
override val id: Long = 0L,
@field:NotBlank
val project: String,
@field:NotBlank
val buggy: String,
@field:NotBlank
val company: String,
@field:Min(1, message = Constants.ValidationMessages.SIZE_GREATER_OR_EQUALS_ONE)
val quantity: Int,
val shippingDate: LocalDate,
val completionDate: LocalDate?,
val completed: Boolean = false,
val expired: Boolean = false,
@field:NotEmpty
val finish: List<String>,
@field:NotEmpty
val material: List<String>,
@field:NotEmpty
val content: List<TouchUpKitProductDto>
) : EntityDto
data class TouchUpKitProductDto(
override val id: Long = 0L,
val name: String,
val description: String?,
val quantity: Float,
val ready: Boolean
) : EntityDto

View File

@ -0,0 +1,94 @@
package dev.fyloz.colorrecipesexplorer.dtos
import com.fasterxml.jackson.annotation.JsonIgnore
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.SpringUserDetails
import dev.fyloz.colorrecipesexplorer.model.account.Permission
import dev.fyloz.colorrecipesexplorer.model.account.toAuthority
import java.time.LocalDateTime
import javax.validation.constraints.NotBlank
import javax.validation.constraints.Size
data class UserDto(
override val id: Long = 0L,
val firstName: String,
val lastName: String,
@field:JsonIgnore
val password: String = "",
val group: GroupDto?,
val permissions: List<Permission>,
val explicitPermissions: List<Permission> = listOf(),
val lastLoginTime: LocalDateTime? = null,
@field:JsonIgnore
val isDefaultGroupUser: Boolean = false,
@field:JsonIgnore
val isSystemUser: Boolean = false
) : EntityDto {
@get:JsonIgnore
val authorities
get() = permissions
.map { it.toAuthority() }
.toMutableSet()
}
data class UserSaveDto(
val id: Long = 0L,
@field:NotBlank
val firstName: String,
@field:NotBlank
val lastName: String,
@field:NotBlank
@field:Size(min = 8, message = Constants.ValidationMessages.PASSWORD_TOO_SMALL)
val password: String,
val groupId: Long?,
val permissions: List<Permission>,
// TODO WN: Test if working
// @JsonProperty(access = JsonProperty.Access.READ_ONLY)
@field:JsonIgnore
val isSystemUser: Boolean = false,
@field:JsonIgnore
val isDefaultGroupUser: Boolean = false
)
data class UserUpdateDto(
val id: Long = 0L,
@field:NotBlank
val firstName: String,
@field:NotBlank
val lastName: String,
val groupId: Long?,
val permissions: List<Permission>
)
data class UserLoginRequestDto(val id: Long, val password: String)
class UserDetails(val user: UserDto) : SpringUserDetails {
override fun getPassword() = user.password
override fun getUsername() = user.id.toString()
override fun getAuthorities() = user.authorities
override fun isAccountNonExpired() = true
override fun isAccountNonLocked() = true
override fun isCredentialsNonExpired() = true
override fun isEnabled() = true
}

View File

@ -0,0 +1,15 @@
package dev.fyloz.colorrecipesexplorer.exception
import org.springframework.http.HttpStatus
class InvalidPositionsException(val errors: Set<InvalidPositionError>) : RestException(
"invalid-positions",
"Invalid positions",
HttpStatus.BAD_REQUEST,
"The positions are invalid",
mapOf(
"errors" to errors
)
)
data class InvalidPositionError(val type: String, val details: String)

View File

@ -0,0 +1,10 @@
package dev.fyloz.colorrecipesexplorer.exception
import org.springframework.http.HttpStatus
class NoDefaultGroupException : RestException(
"nodefaultgroup",
"No default group",
HttpStatus.NOT_FOUND,
"No default group cookie is defined in the current request"
)

View File

@ -0,0 +1,38 @@
package dev.fyloz.colorrecipesexplorer.logic
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent
import dev.fyloz.colorrecipesexplorer.dtos.CompanyDto
import dev.fyloz.colorrecipesexplorer.service.CompanyService
interface CompanyLogic : Logic<CompanyDto, CompanyService>
@LogicComponent
class DefaultCompanyLogic(service: CompanyService) :
BaseLogic<CompanyDto, CompanyService>(service, Constants.ModelNames.COMPANY), CompanyLogic {
override fun save(dto: CompanyDto): CompanyDto {
throwIfNameAlreadyExists(dto.name)
return super.save(dto)
}
override fun update(dto: CompanyDto): CompanyDto {
throwIfNameAlreadyExists(dto.name, dto.id)
return super.update(dto)
}
override fun deleteById(id: Long) {
if (service.isUsedByRecipe(id)) {
throw cannotDeleteException("Cannot delete the company with the id '$id' because one or more recipes depends on it")
}
super.deleteById(id)
}
private fun throwIfNameAlreadyExists(name: String, id: Long? = null) {
if (service.existsByName(name, id)) {
throw alreadyExistsException(value = name)
}
}
}

View File

@ -0,0 +1,125 @@
package dev.fyloz.colorrecipesexplorer.logic
import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase
import dev.fyloz.colorrecipesexplorer.dtos.MaterialDto
import dev.fyloz.colorrecipesexplorer.dtos.MaterialQuantityDto
import dev.fyloz.colorrecipesexplorer.dtos.MixDeductDto
import dev.fyloz.colorrecipesexplorer.dtos.MixMaterialDto
import dev.fyloz.colorrecipesexplorer.exception.RestException
import dev.fyloz.colorrecipesexplorer.model.Material
import dev.fyloz.colorrecipesexplorer.utils.mapMayThrow
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import javax.transaction.Transactional
interface InventoryLogic {
/** Adds each given [MaterialQuantityDto] to the inventory and returns the updated quantities. */
fun add(materialQuantities: Collection<MaterialQuantityDto>): Collection<MaterialQuantityDto>
/** Adds a given quantity to the given [Material]'s inventory quantity according to the given [materialQuantity] and returns the updated quantity. */
fun add(materialQuantity: MaterialQuantityDto): Float
/** Deducts the inventory quantity of each [Material]s in the mix according to the ratio defined in the given [mixRatio] and returns the updated quantities. */
fun deductMix(mixRatio: MixDeductDto): Collection<MaterialQuantityDto>
/** Deducts the inventory quantity of each given [MaterialQuantityDto] and returns the updated quantities. */
fun deduct(materialQuantities: Collection<MaterialQuantityDto>): Collection<MaterialQuantityDto>
/** Deducts the inventory quantity of a given [Material] by a given quantity according to the given [materialQuantity] and returns the updated quantity. */
fun deduct(materialQuantity: MaterialQuantityDto): Float
}
@Service
@RequireDatabase
class DefaultInventoryLogic(
private val materialLogic: MaterialLogic,
private val mixLogic: MixLogic
) : InventoryLogic {
@Transactional
override fun add(materialQuantities: Collection<MaterialQuantityDto>) =
materialQuantities.map { MaterialQuantityDto(it.materialId, add(it)) }
override fun add(materialQuantity: MaterialQuantityDto) =
materialLogic.updateQuantity(
materialLogic.getById(materialQuantity.materialId),
materialQuantity.quantity
)
@Transactional
override fun deductMix(mixRatio: MixDeductDto): Collection<MaterialQuantityDto> {
val mix = mixLogic.getById(mixRatio.id)
val mixMaterials = mix.mixQuantities.materials
if (mixMaterials.isEmpty()) return listOf()
return deduct(getMaterialsWithAdjustedQuantities(mixMaterials, mixRatio))
}
@Transactional
override fun deduct(materialQuantities: Collection<MaterialQuantityDto>): Collection<MaterialQuantityDto> {
val thrown = mutableListOf<NotEnoughInventoryException>()
val updatedQuantities =
materialQuantities.mapMayThrow<MaterialQuantityDto, MaterialQuantityDto, NotEnoughInventoryException>(
{ thrown.add(it) }
) {
MaterialQuantityDto(it.materialId, deduct(it))
}
if (thrown.isNotEmpty()) {
throw MultiplesNotEnoughInventoryException(thrown)
}
return updatedQuantities
}
override fun deduct(materialQuantity: MaterialQuantityDto): Float =
with(materialLogic.getById(materialQuantity.materialId)) {
if (this.inventoryQuantity >= materialQuantity.quantity) {
materialLogic.updateQuantity(this, -materialQuantity.quantity)
} else {
throw NotEnoughInventoryException(materialQuantity.quantity, this)
}
}
private fun getMaterialsWithAdjustedQuantities(
mixMaterials: Collection<MixMaterialDto>,
mixRatio: MixDeductDto
): Collection<MaterialQuantityDto> {
val adjustedFirstMaterialQuantity = mixMaterials.first().quantity * mixRatio.ratio
fun getAdjustedQuantity(material: MaterialDto, quantity: Float) =
if (!material.materialType.usePercentages)
quantity * mixRatio.ratio // Simply multiply the quantity by the ratio
else
(quantity * adjustedFirstMaterialQuantity) / 100f // Percents quantities are a ratio of the first material
return mixMaterials.associate { it.material to it.quantity }
.mapValues { getAdjustedQuantity(it.key, it.value) }
.map { MaterialQuantityDto(it.key.id, it.value) }
}
}
class NotEnoughInventoryException(quantity: Float, material: MaterialDto) :
RestException(
"notenoughinventory",
"Not enough inventory",
HttpStatus.BAD_REQUEST,
"Cannot deduct ${quantity}mL of ${material.name} because there is only ${material.inventoryQuantity}mL in inventory",
mapOf(
"material" to material.name,
"materialId" to material.id.toString(),
"requestQuantity" to quantity,
"availableQuantity" to material.inventoryQuantity
)
)
class MultiplesNotEnoughInventoryException(exceptions: List<NotEnoughInventoryException>) :
RestException(
"notenoughinventory-multiple",
"Not enough inventory",
HttpStatus.BAD_REQUEST,
"Cannot deduct requested quantities because there is no enough of them in inventory",
mapOf(
"lowQuantities" to exceptions.map { it.extensions }
)
)

View File

@ -0,0 +1,101 @@
package dev.fyloz.colorrecipesexplorer.logic
import dev.fyloz.colorrecipesexplorer.dtos.EntityDto
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import dev.fyloz.colorrecipesexplorer.service.Service
import dev.fyloz.colorrecipesexplorer.utils.collections.LazyMapList
import org.springframework.transaction.annotation.Transactional
/**
* Represents the logic for a DTO type.
*
* @param D The type of the DTO.
* @param S The service for the DTO.
*/
interface Logic<D : EntityDto, S : Service<D, *, *>> {
/** Checks if a DTO with the given [id] exists. */
fun existsById(id: Long): Boolean
/** Get all DTOs. */
fun getAll(): Collection<D>
/** Get the DTO for the given [id]. Throws if no DTO were found. */
fun getById(id: Long): D
/** Saves the given [dto]. */
fun save(dto: D): D
/** Saves all the given [dtos]. */
fun saveAll(dtos: Collection<D>): Collection<D>
/** Updates the given [dto]. Throws if no DTO with the same id exists. */
fun update(dto: D): D
/** Deletes the dto with the given [id]. */
fun deleteById(id: Long)
}
abstract class BaseLogic<D : EntityDto, S : Service<D, *, *>>(
protected val service: S,
protected val typeName: String
) : Logic<D, S> {
protected val typeNameLowerCase = typeName.lowercase()
override fun existsById(id: Long) =
service.existsById(id)
override fun getAll() =
service.getAll()
override fun getById(id: Long) =
service.getById(id) ?: throw notFoundException(value = id)
override fun save(dto: D) =
service.save(dto)
override fun saveAll(dtos: Collection<D>) =
dtos.map(::save)
override fun update(dto: D): D {
if (!existsById(dto.id)) {
throw notFoundException(value = dto.id)
}
return service.save(dto)
}
override fun deleteById(id: Long) =
service.deleteById(id)
protected fun notFoundException(identifierName: String = ID_IDENTIFIER_NAME, value: Any) =
NotFoundException(
typeNameLowerCase,
"$typeName not found",
"A $typeNameLowerCase with the $identifierName '$value' could not be found",
value,
identifierName
)
protected fun alreadyExistsException(identifierName: String = NAME_IDENTIFIER_NAME, value: Any) =
AlreadyExistsException(
typeNameLowerCase,
"$typeName already exists",
"A $typeNameLowerCase with the $identifierName '$value' already exists",
value,
identifierName
)
protected fun cannotDeleteException(details: String) =
CannotDeleteException(
typeNameLowerCase,
"Cannot delete $typeNameLowerCase",
details
)
companion object {
const val ID_IDENTIFIER_NAME = "id"
const val NAME_IDENTIFIER_NAME = "name"
}
}

View File

@ -0,0 +1,121 @@
package dev.fyloz.colorrecipesexplorer.logic
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent
import dev.fyloz.colorrecipesexplorer.dtos.MaterialDto
import dev.fyloz.colorrecipesexplorer.dtos.MaterialSaveDto
import dev.fyloz.colorrecipesexplorer.dtos.MixTypeDto
import dev.fyloz.colorrecipesexplorer.logic.files.WriteableFileLogic
import dev.fyloz.colorrecipesexplorer.model.Material
import dev.fyloz.colorrecipesexplorer.service.MaterialService
interface MaterialLogic : Logic<MaterialDto, MaterialService> {
/** Checks if a material with the given [name] exists. */
fun existsByName(name: String): Boolean
/**
* Returns every material available in the context of the recipe with the given [recipeId].
* The materials included contains every non mix type material, and the materials generated for the recipe mix types.
*/
fun getAllForRecipe(recipeId: Long): Collection<MaterialDto>
/**
* Returns every material available in the context of the mix with the given [mixId].
* The materials included contains every non mix type material, and the materials generated for
* the mix's recipe mix types, excluding the mix's mix type.
*/
fun getAllForMix(mixId: Long): Collection<MaterialDto>
/** Saves the given [dto]. */
fun save(dto: MaterialSaveDto): MaterialDto
/** Updates the given [dto]. */
fun update(dto: MaterialSaveDto): MaterialDto
/** Updates the quantity of the given [material] with the given [factor] and returns the updated quantity. */
fun updateQuantity(material: MaterialDto, factor: Float): Float
}
@LogicComponent
class DefaultMaterialLogic(
service: MaterialService,
val recipeLogic: RecipeLogic,
val mixLogic: MixLogic,
val materialTypeLogic: MaterialTypeLogic,
val fileLogic: WriteableFileLogic
) : BaseLogic<MaterialDto, MaterialService>(service, Constants.ModelNames.MATERIAL), MaterialLogic {
override fun existsByName(name: String) = service.existsByName(name, null)
override fun getAllForRecipe(recipeId: Long): Collection<MaterialDto> {
val recipe = recipeLogic.getById(recipeId)
return getAllWithMixTypesMaterials(recipe.mixTypes)
}
override fun getAllForMix(mixId: Long): Collection<MaterialDto> {
val mix = mixLogic.getById(mixId)
val recipe = recipeLogic.getById(mix.recipeId)
val availableMixTypes = recipe.mixTypes.filter { it != mix.mixType }
return getAllWithMixTypesMaterials(availableMixTypes)
}
private fun getAllWithMixTypesMaterials(mixTypes: Collection<MixTypeDto>) =
getAll() + mixTypes.map { it.asMaterial() }
override fun save(dto: MaterialSaveDto) = save(saveDtoToDto(dto, false)).also { saveSimdutFile(dto, false) }
override fun save(dto: MaterialDto): MaterialDto {
throwIfNameAlreadyExists(dto.name)
return super.save(dto)
}
override fun update(dto: MaterialSaveDto) = update(saveDtoToDto(dto, true)).also { saveSimdutFile(dto, true) }
override fun update(dto: MaterialDto): MaterialDto {
throwIfNameAlreadyExists(dto.name, dto.id)
return super.update(dto)
}
override fun updateQuantity(material: MaterialDto, factor: Float): Float {
val updatedQuantity = material.inventoryQuantity + factor
service.updateInventoryQuantityById(material.id, updatedQuantity)
return updatedQuantity
}
override fun deleteById(id: Long) {
if (service.isUsedByMixMaterialOrMixType(id)) {
throw cannotDeleteException("Cannot delete the material with the id '$id' because mix types and/or recipes depends on it")
}
val material = getById(id)
val simdutPath = Material.getSimdutFilePath(material.name)
if (fileLogic.exists(simdutPath)) {
fileLogic.delete(simdutPath)
}
super.deleteById(id)
}
private fun saveDtoToDto(saveDto: MaterialSaveDto, updating: Boolean): MaterialDto {
val isMixType = !updating || getById(saveDto.id).isMixType
val materialType = materialTypeLogic.getById(saveDto.materialTypeId)
return MaterialDto(saveDto.id, saveDto.name, saveDto.inventoryQuantity, isMixType, materialType)
}
private fun saveSimdutFile(dto: MaterialSaveDto, updating: Boolean) {
val file = dto.simdutFile
if (file != null && !file.isEmpty) {
fileLogic.write(file, Material.getSimdutFilePath(dto.name), updating)
}
}
private fun throwIfNameAlreadyExists(name: String, id: Long? = null) {
if (service.existsByName(name, id)) {
throw alreadyExistsException(value = name)
}
}
}

View File

@ -0,0 +1,75 @@
package dev.fyloz.colorrecipesexplorer.logic
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent
import dev.fyloz.colorrecipesexplorer.dtos.MaterialTypeDto
import dev.fyloz.colorrecipesexplorer.exception.CannotUpdateException
import dev.fyloz.colorrecipesexplorer.service.MaterialTypeService
interface MaterialTypeLogic : Logic<MaterialTypeDto, MaterialTypeService> {
/** Gets all material types which are or not [systemType]s. */
fun getAll(systemType: Boolean): Collection<MaterialTypeDto>
/** Gets the material type with the given [name]. */
fun getByName(name: String): MaterialTypeDto?
/** Updates the given [dto], and throws if it is a system types. */
fun updateNonSystemType(dto: MaterialTypeDto)
}
@LogicComponent
class DefaultMaterialTypeLogic(service: MaterialTypeService) :
BaseLogic<MaterialTypeDto, MaterialTypeService>(service, Constants.ModelNames.MATERIAL_TYPE), MaterialTypeLogic {
override fun getAll(systemType: Boolean) = service.getAll(systemType)
override fun getByName(name: String) = service.getByName(name)
override fun updateNonSystemType(dto: MaterialTypeDto) {
if (service.existsById(dto.id, true)) {
throw CannotUpdateException(
typeNameLowerCase,
"Cannot update $typeNameLowerCase",
"Cannot update material type '${dto.name}' because it is a system material type"
)
}
update(dto)
}
override fun save(dto: MaterialTypeDto): MaterialTypeDto {
throwIfNameAlreadyExists(dto.name)
throwIfPrefixAlreadyExists(dto.prefix)
return super.save(dto)
}
override fun update(dto: MaterialTypeDto): MaterialTypeDto {
throwIfNameAlreadyExists(dto.name, dto.id)
throwIfPrefixAlreadyExists(dto.prefix, dto.id)
return super.update(dto)
}
override fun deleteById(id: Long) {
if (service.isUsedByMaterial(id)) {
throw cannotDeleteException("Cannot delete material type with the id '$id' because one or more materials depends on it")
}
super.deleteById(id)
}
private fun throwIfNameAlreadyExists(name: String, id: Long? = null) {
if (service.existsByName(name, id)) {
throw alreadyExistsException(value = name)
}
}
private fun throwIfPrefixAlreadyExists(prefix: String, id: Long? = null) {
if (service.existsByPrefix(prefix, id)) {
throw alreadyExistsException(PREFIX_IDENTIFIER_NAME, prefix)
}
}
companion object {
const val PREFIX_IDENTIFIER_NAME = "prefix"
}
}

View File

@ -0,0 +1,73 @@
package dev.fyloz.colorrecipesexplorer.logic
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent
import dev.fyloz.colorrecipesexplorer.dtos.MixDto
import dev.fyloz.colorrecipesexplorer.dtos.MixLocationDto
import dev.fyloz.colorrecipesexplorer.dtos.MixSaveDto
import dev.fyloz.colorrecipesexplorer.service.MixService
import org.springframework.context.annotation.Lazy
import org.springframework.transaction.annotation.Transactional
interface MixLogic : Logic<MixDto, MixService> {
/** Saves the given [dto]. */
fun save(dto: MixSaveDto): MixDto
/** Updates the given [dto]. */
fun update(dto: MixSaveDto): MixDto
/** Updates the location of each mix in the given [updatedLocations]. */
fun updateLocations(updatedLocations: Collection<MixLocationDto>)
}
@LogicComponent
class DefaultMixLogic(
service: MixService,
@Lazy private val recipeLogic: RecipeLogic,
@Lazy private val materialTypeLogic: MaterialTypeLogic,
private val mixTypeLogic: MixTypeLogic,
private val mixQuantityLogic: MixQuantityLogic
) : BaseLogic<MixDto, MixService>(service, Constants.ModelNames.MIX), MixLogic {
@Transactional
override fun save(dto: MixSaveDto): MixDto {
val recipe = recipeLogic.getById(dto.recipeId)
val materialType = materialTypeLogic.getById(dto.materialTypeId)
val mix = MixDto(
recipeId = recipe.id,
mixType = mixTypeLogic.getOrCreateForNameAndMaterialType(dto.name, materialType),
mixQuantities = mixQuantityLogic.validateAndPrepareForMix(dto.mixQuantities)
)
return save(mix)
}
@Transactional
override fun update(dto: MixSaveDto): MixDto {
val materialType = materialTypeLogic.getById(dto.materialTypeId)
val mix = getById(dto.id)
// Update the mix type if it has been changed
val mixType = if (mix.mixType.name != dto.name || mix.mixType.materialType.id != dto.materialTypeId) {
mixTypeLogic.updateOrCreateForNameAndMaterialType(mix.mixType, dto.name, materialType)
} else {
mix.mixType
}
return update(
MixDto(
id = dto.id,
recipeId = mix.recipeId,
mixType = mixType,
mixQuantities = mixQuantityLogic.validateAndPrepareForMix(dto.mixQuantities)
)
)
}
override fun updateLocations(updatedLocations: Collection<MixLocationDto>) =
updatedLocations.forEach(::updateLocation)
private fun updateLocation(updatedLocation: MixLocationDto) {
service.updateLocationById(updatedLocation.mixId, updatedLocation.location)
}
}

View File

@ -0,0 +1,95 @@
package dev.fyloz.colorrecipesexplorer.logic
import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent
import dev.fyloz.colorrecipesexplorer.dtos.*
import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionError
import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionsException
import dev.fyloz.colorrecipesexplorer.exception.RestException
import dev.fyloz.colorrecipesexplorer.utils.PositionUtils
import org.springframework.context.annotation.Lazy
import org.springframework.http.HttpStatus
interface MixQuantityLogic {
/**
* Validates if the given [mixMaterials]. To be valid, the position of each mix material must be greater or equals to 1 and unique in the set.
* There must also be no gap between the positions. Also, the quantity of the first mix material in the set must not be expressed in percentages.
* If any of those criteria are not met, an [InvalidGroupStepsPositionsException] will be thrown.
*/
fun validateMixQuantities(mixMaterials: List<MixQuantityDto>)
/** Validates the given mix quantities [dtos] and put them in [MixQuantitiesDto] to be consumed by a mix. */
fun validateAndPrepareForMix(dtos: List<MixQuantitySaveDto>): MixQuantitiesDto
}
@LogicComponent
class DefaultMixQuantityLogic(
@Lazy private val materialLogic: MaterialLogic,
private val mixTypeLogic: MixTypeLogic
) : MixQuantityLogic {
override fun validateMixQuantities(mixMaterials: List<MixQuantityDto>) {
if (mixMaterials.isEmpty()) return
val sortedMixMaterials = mixMaterials.sortedBy { it.position }
try {
PositionUtils.validate(sortedMixMaterials.map { it.position })
} catch (ex: InvalidPositionsException) {
throw InvalidMixMaterialsPositionsException(ex.errors)
}
val firstMixMaterial = sortedMixMaterials[0]
if (firstMixMaterial is MixMaterialDto) {
if (firstMixMaterial.material.materialType.usePercentages) {
throw InvalidFirstMixMaterialException(sortedMixMaterials[0])
}
}
}
override fun validateAndPrepareForMix(dtos: List<MixQuantitySaveDto>): MixQuantitiesDto {
val mixMixTypes = dtos.filter { it.isMixType }.map {
MixMixTypeDto(
id = it.id,
mixType = mixTypeLogic.getById(it.materialId),
quantity = it.quantity,
position = it.position
)
}
val mixMaterials = dtos.filter { !it.isMixType }.map {
MixMaterialDto(
id = it.id,
material = materialLogic.getById(it.materialId),
quantity = it.quantity,
position = it.position
)
}
validateMixQuantities(mixMixTypes + mixMaterials)
return MixQuantitiesDto(mixMaterials, mixMixTypes)
}
}
// TODO check if required
class InvalidMixMaterialsPositionsException(
val errors: Set<InvalidPositionError>
) : RestException(
"invalid-mixmaterial-position",
"Invalid mix materials positions",
HttpStatus.BAD_REQUEST,
"The position of mix materials are invalid",
mapOf(
"invalidMixMaterials" to errors
)
)
class InvalidFirstMixMaterialException(
val mixMaterial: MixQuantityDto
) : RestException(
"invalid-mixmaterial-first",
"Invalid first mix material",
HttpStatus.BAD_REQUEST,
"The first mix material is invalid because its material must not be expressed in percents",
mapOf(
"mixMaterial" to mixMaterial
)
)

View File

@ -0,0 +1,59 @@
package dev.fyloz.colorrecipesexplorer.logic
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent
import dev.fyloz.colorrecipesexplorer.dtos.MaterialTypeDto
import dev.fyloz.colorrecipesexplorer.dtos.MixTypeDto
import dev.fyloz.colorrecipesexplorer.service.MixTypeService
import org.springframework.transaction.annotation.Transactional
interface MixTypeLogic : Logic<MixTypeDto, MixTypeService> {
/** Returns a mix type for the given [name] and [materialType]. If this mix type does not already exist, it will be created. */
fun getOrCreateForNameAndMaterialType(name: String, materialType: MaterialTypeDto): MixTypeDto
/** Updates the [mixType] with the given [name] and [materialType], or create a new one if it is shared with other mixes. */
fun updateOrCreateForNameAndMaterialType(
mixType: MixTypeDto,
name: String,
materialType: MaterialTypeDto
): MixTypeDto
}
@LogicComponent
class DefaultMixTypeLogic(service: MixTypeService) : BaseLogic<MixTypeDto, MixTypeService>(service, Constants.ModelNames.MIX_TYPE), MixTypeLogic {
@Transactional
override fun getOrCreateForNameAndMaterialType(name: String, materialType: MaterialTypeDto) =
service.getByNameAndMaterialType(name, materialType.id) ?: saveForNameAndMaterialType(name, materialType)
override fun updateOrCreateForNameAndMaterialType(
mixType: MixTypeDto,
name: String,
materialType: MaterialTypeDto
) = if (service.existsByNameAndMaterialType(name, materialType.id, mixType.id)) {
service.getByNameAndMaterialType(name, materialType.id)!!
} else if (service.isShared(mixType.id)) {
saveForNameAndMaterialType(name, materialType)
} else {
updateForNameAndMaterialType(mixType, name, materialType)
}
override fun deleteById(id: Long) {
if (service.isUsedByMixes(id)) {
throw cannotDeleteException("Cannot delete the mix type with the id '$id' because one or more mixes depends on it")
}
super.deleteById(id)
}
private fun saveForNameAndMaterialType(name: String, materialType: MaterialTypeDto): MixTypeDto {
return save(MixTypeDto(name = name, materialType = materialType))
}
private fun updateForNameAndMaterialType(
mixType: MixTypeDto,
name: String,
materialType: MaterialTypeDto
): MixTypeDto {
return update(mixType.copy(name = name, materialType = materialType, material = mixType.material))
}
}

View File

@ -0,0 +1,190 @@
package dev.fyloz.colorrecipesexplorer.logic
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent
import dev.fyloz.colorrecipesexplorer.dtos.*
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.logic.files.WriteableFileLogic
import dev.fyloz.colorrecipesexplorer.logic.users.GroupLogic
import dev.fyloz.colorrecipesexplorer.service.RecipeService
import dev.fyloz.colorrecipesexplorer.utils.collections.LazyMapList
import dev.fyloz.colorrecipesexplorer.utils.merge
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
interface RecipeLogic : Logic<RecipeDto, RecipeService> {
/** Gets all recipes and load their mixes and groupsInformation, to prevent LazyInitializationExceptions */
fun getAllWithMixesAndGroupsInformation(): Collection<RecipeDto>
/** Gets all recipes with the given [name]. */
fun getAllByName(name: String): Collection<RecipeDto>
/** Saves the given [dto]. */
fun save(dto: RecipeSaveDto): RecipeDto
/** Updates the given [dto]. */
fun update(dto: RecipeUpdateDto): RecipeDto
/** Updates the public data of a recipe with the given [publicDataDto]. */
fun updatePublicData(publicDataDto: RecipePublicDataDto)
}
@LogicComponent
class DefaultRecipeLogic(
service: RecipeService,
private val companyLogic: CompanyLogic,
private val recipeStepLogic: RecipeStepLogic,
private val mixLogic: MixLogic,
private val groupLogic: GroupLogic
) : BaseLogic<RecipeDto, RecipeService>(service, Constants.ModelNames.RECIPE), RecipeLogic {
@Transactional
override fun getAllWithMixesAndGroupsInformation() =
getAll().onEach { (it.mixes as LazyMapList<*, *>).initialize() }
.onEach { (it.groupsInformation as LazyMapList<*, *>).initialize() }
override fun getAllByName(name: String) = service.getAllByName(name)
override fun save(dto: RecipeSaveDto) = save(
RecipeDto(
name = dto.name,
description = dto.description,
color = dto.color,
gloss = dto.gloss,
sample = dto.sample,
approbationDate = dto.approbationDate,
approbationExpired = false,
remark = dto.remark ?: "",
company = companyLogic.getById(dto.companyId),
mixes = listOf(),
groupsInformation = listOf()
)
)
override fun save(dto: RecipeDto): RecipeDto {
throwIfNameAndCompanyAlreadyExists(dto.name, dto.company.id)
return super.save(dto)
}
override fun update(dto: RecipeUpdateDto): RecipeDto {
val recipe = getById(dto.id)
return update(
RecipeDto(
id = dto.id,
name = dto.name,
description = dto.description,
color = dto.color,
gloss = dto.gloss,
sample = dto.sample,
approbationDate = dto.approbationDate,
approbationExpired = false,
remark = dto.remark ?: "",
company = recipe.company,
mixes = recipe.mixes,
groupsInformation = updateGroupsInformationSteps(recipe, dto)
)
)
}
override fun update(dto: RecipeDto): RecipeDto {
throwIfNameAndCompanyAlreadyExists(dto.name, dto.company.id, dto.id)
return super.update(dto)
}
@Transactional
override fun updatePublicData(publicDataDto: RecipePublicDataDto) {
// Update notes
if (publicDataDto.notes.isNotEmpty()) {
val recipe = getById(publicDataDto.recipeId)
update(recipe.copy(groupsInformation = updateGroupsInformationNotes(recipe, publicDataDto.notes)))
}
// Update mixes locations
if (publicDataDto.mixesLocation.isNotEmpty()) {
mixLogic.updateLocations(publicDataDto.mixesLocation)
}
}
private fun updateGroupsInformationSteps(recipe: RecipeDto, dto: RecipeUpdateDto): List<RecipeGroupInformationDto> {
val updatedGroupsInformation = dto.steps.map { updateGroupInformationSteps(recipe, it) }
return recipe.groupsInformation.merge(updatedGroupsInformation)
}
private fun updateGroupInformationSteps(recipe: RecipeDto, groupSteps: RecipeGroupStepsDto) =
getOrCreateGroupInformation(recipe, groupSteps.groupId).copy(steps = groupSteps.steps).also {
recipeStepLogic.validateGroupInformationSteps(it)
}
private fun updateGroupsInformationNotes(
recipe: RecipeDto, notes: List<RecipeGroupNoteDto>
): List<RecipeGroupInformationDto> {
val updatedGroupsInformation = notes.map { updateGroupInformationNote(recipe, it) }
return recipe.groupsInformation.merge(updatedGroupsInformation)
}
private fun updateGroupInformationNote(recipe: RecipeDto, groupNote: RecipeGroupNoteDto) =
getOrCreateGroupInformation(recipe, groupNote.groupId).copy(note = groupNote.content)
private fun getOrCreateGroupInformation(recipe: RecipeDto, groupId: Long) =
recipe.groupsInformation.firstOrNull { it.group.id == groupId }
?: RecipeGroupInformationDto(group = groupLogic.getById(groupId))
private fun throwIfNameAndCompanyAlreadyExists(name: String, companyId: Long, id: Long? = null) {
if (service.existsByNameAndCompany(name, companyId, id)) {
throw AlreadyExistsException(
"$typeNameLowerCase-company",
"$typeName already exists",
"A recipe with the name '$name' already exists for the company with the id '$companyId'",
name,
NAME_IDENTIFIER_NAME,
mutableMapOf(
"companyId" to companyId
)
)
}
}
}
interface RecipeImageLogic {
/** Gets the id of every image associated to the recipe with the given [recipeId]. */
fun getAllImages(recipeId: Long): List<String>
/** Saves the given [image] and associate it to the recipe with the given [recipeId]. Returns the id of the saved image. */
fun download(image: MultipartFile, recipeId: Long): String
/** Deletes the image with the given [id] for the given [recipeId]. */
fun delete(recipeId: Long, id: String)
}
@LogicComponent
class DefaultRecipeImageLogic(val fileLogic: WriteableFileLogic) : RecipeImageLogic {
override fun getAllImages(recipeId: Long) =
fileLogic.listDirectoryFiles(getRecipeImagesDirectory(recipeId)).map { it.name }
override fun download(image: MultipartFile, recipeId: Long): String {
/** Gets the next id available for a new image for the given [recipeId]. */
fun getNextAvailableId(): String = with(getAllImages(recipeId)) {
val currentIds = mapNotNull { it.toLongOrNull() }
if (currentIds.isEmpty()) {
return 0.toString()
}
val nextId = currentIds.maxOf { it } + 1L
return nextId.toString()
}
return getNextAvailableId().also {
val imagePath = getImagePath(recipeId, it)
fileLogic.writeToDirectory(image, imagePath, getRecipeImagesDirectory(recipeId), true)
}
}
override fun delete(recipeId: Long, id: String) =
fileLogic.deleteFromDirectory(getImagePath(recipeId, id), getRecipeImagesDirectory(recipeId))
private fun getImagePath(recipeId: Long, id: String) = "${getRecipeImagesDirectory(recipeId)}/$id"
private fun getRecipeImagesDirectory(recipeId: Long) = "${Constants.FilePaths.RECIPE_IMAGES}/$recipeId"
}

View File

@ -0,0 +1,49 @@
package dev.fyloz.colorrecipesexplorer.logic
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent
import dev.fyloz.colorrecipesexplorer.dtos.GroupDto
import dev.fyloz.colorrecipesexplorer.dtos.RecipeGroupInformationDto
import dev.fyloz.colorrecipesexplorer.dtos.RecipeStepDto
import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionError
import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionsException
import dev.fyloz.colorrecipesexplorer.exception.RestException
import dev.fyloz.colorrecipesexplorer.model.account.Group
import dev.fyloz.colorrecipesexplorer.service.RecipeStepService
import dev.fyloz.colorrecipesexplorer.utils.PositionUtils
import org.springframework.http.HttpStatus
interface RecipeStepLogic : Logic<RecipeStepDto, RecipeStepService> {
/** Validates the steps of the given [groupInformation], according to the criteria of [PositionUtils.validate]. */
fun validateGroupInformationSteps(groupInformation: RecipeGroupInformationDto)
}
@LogicComponent
class DefaultRecipeStepLogic(recipeStepService: RecipeStepService) :
BaseLogic<RecipeStepDto, RecipeStepService>(recipeStepService, Constants.ModelNames.RECIPE_STEP), RecipeStepLogic {
override fun validateGroupInformationSteps(groupInformation: RecipeGroupInformationDto) {
try {
PositionUtils.validate(groupInformation.steps.map { it.position }.toList())
} catch (ex: InvalidPositionsException) {
throw InvalidGroupStepsPositionsException(groupInformation.group, ex)
}
}
}
class InvalidGroupStepsPositionsException(
val group: GroupDto,
val exception: InvalidPositionsException
) : RestException(
"invalid-groupinformation-recipestep-position",
"Invalid steps positions",
HttpStatus.BAD_REQUEST,
"The position of steps for the group ${group.name} are invalid",
mapOf(
"group" to group.name,
"groupId" to group.id,
"invalidSteps" to exception.errors
)
) {
val errors: Set<InvalidPositionError>
get() = exception.errors
}

View File

@ -0,0 +1,95 @@
package dev.fyloz.colorrecipesexplorer.logic
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent
import dev.fyloz.colorrecipesexplorer.dtos.TouchUpKitDto
import dev.fyloz.colorrecipesexplorer.logic.config.ConfigurationLogic
import dev.fyloz.colorrecipesexplorer.logic.files.WriteableFileLogic
import dev.fyloz.colorrecipesexplorer.model.ConfigurationType
import dev.fyloz.colorrecipesexplorer.service.TouchUpKitService
import dev.fyloz.colorrecipesexplorer.utils.*
import org.springframework.core.io.ByteArrayResource
import org.springframework.core.io.Resource
import java.time.LocalDate
interface TouchUpKitLogic : Logic<TouchUpKitDto, TouchUpKitService> {
/** Sets the touch up kit with the given [id] as complete. */
fun complete(id: Long)
/**
* Generates and returns a [PdfDocument] for the given [job] as a [ByteArrayResource].
*
* If TOUCH_UP_KIT_CACHE_PDF is enabled and a file exists for the job, its content will be returned.
* If caching is enabled but no file exists for the job, the generated ByteArrayResource will be cached on the disk.
*/
fun generateJobPdfResource(job: String): Resource
/** Generates and returns a [PdfDocument] for the given [job]. */
fun generateJobPdf(job: String): PdfDocument
/** Writes the given [pdf] to the disk if TOUCH_UP_KIT_CACHE_PDF is enabled. */
fun cacheJobPdf(job: String, pdf: PdfDocument)
}
@LogicComponent
class DefaultTouchUpKitLogic(
service: TouchUpKitService,
private val fileLogic: WriteableFileLogic,
private val configLogic: ConfigurationLogic
) : BaseLogic<TouchUpKitDto, TouchUpKitService>(service, Constants.ModelNames.TOUCH_UP_KIT), TouchUpKitLogic {
private val cacheGeneratedFiles by lazy {
configLogic.getContent(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF) == true.toString()
}
override fun complete(id: Long) = service.updateCompletionDateById(id, LocalDate.now())
override fun generateJobPdfResource(job: String): Resource {
if (cacheGeneratedFiles) {
val pdfPath = jobPdfPath(job)
if (fileLogic.exists(pdfPath)) {
return fileLogic.read(pdfPath)
}
}
val pdf = generateJobPdf(job)
cacheJobPdf(job, pdf)
return pdf.toByteArrayResource()
}
override fun generateJobPdf(job: String) = pdf {
container {
centeredVertically = true
drawContainerBottom = true
text(TOUCH_UP_TEXT_FR) {
bold = true
fontSize = PDF_DEFAULT_FONT_SIZE + 12
}
text(TOUCH_UP_TEXT_EN) {
bold = true
fontSize = PDF_DEFAULT_FONT_SIZE + 12
}
text(job) {
marginTop = 10f
}
}
container(containers[0]) {
drawContainerBottom = false
}
}
override fun cacheJobPdf(job: String, pdf: PdfDocument) {
if (!cacheGeneratedFiles) return
fileLogic.write(pdf.toByteArrayResource(), jobPdfPath(job), true)
}
private fun jobPdfPath(job: String) =
"${Constants.FilePaths.TOUCH_UP_KITS}/$job.pdf"
companion object {
const val TOUCH_UP_TEXT_FR = "KIT DE RETOUCHE"
const val TOUCH_UP_TEXT_EN = "TOUCH UP KIT"
}
}

View File

@ -1,19 +1,19 @@
package dev.fyloz.colorrecipesexplorer.service.config
package dev.fyloz.colorrecipesexplorer.logic.config
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
import dev.fyloz.colorrecipesexplorer.logic.files.ResourceFileLogic
import dev.fyloz.colorrecipesexplorer.logic.files.WriteableFileLogic
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.service.files.ResourceFileService
import dev.fyloz.colorrecipesexplorer.service.files.WriteableFileService
import dev.fyloz.colorrecipesexplorer.utils.decrypt
import dev.fyloz.colorrecipesexplorer.utils.encrypt
import org.slf4j.Logger
import mu.KotlinLogging
import org.springframework.context.annotation.Lazy
import org.springframework.core.io.Resource
import org.springframework.security.crypto.keygen.KeyGenerators
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
interface ConfigurationService {
interface ConfigurationLogic {
/** Gets all set configurations. */
fun getAll(): List<ConfigurationBase>
@ -73,13 +73,13 @@ const val CONFIGURATION_ICON_FILE_PATH = "images/icon"
const val CONFIGURATION_FORMATTED_LIST_DELIMITER = ';'
@Service("configurationService")
class ConfigurationServiceImpl(
@Lazy private val fileService: WriteableFileService,
private val resourceFileService: ResourceFileService,
class DefaultConfigurationLogic(
@Lazy private val fileService: WriteableFileLogic,
private val resourceFileService: ResourceFileLogic,
private val configurationSource: ConfigurationSource,
private val securityProperties: CreSecurityProperties,
private val logger: Logger
) : ConfigurationService {
private val securityProperties: CreSecurityProperties
) : ConfigurationLogic {
private val logger = KotlinLogging.logger { }
private val saltConfigurationType = ConfigurationType.GENERATED_ENCRYPTION_SALT
private val encryptionSalt by lazy {
securityProperties.configSalt ?: getGeneratedSalt()

View File

@ -1,4 +1,4 @@
package dev.fyloz.colorrecipesexplorer.service.config
package dev.fyloz.colorrecipesexplorer.logic.config
import dev.fyloz.colorrecipesexplorer.JavaFile
import dev.fyloz.colorrecipesexplorer.SUPPORTED_DATABASE_VERSION
@ -11,7 +11,7 @@ import dev.fyloz.colorrecipesexplorer.model.configuration
import dev.fyloz.colorrecipesexplorer.repository.ConfigurationRepository
import dev.fyloz.colorrecipesexplorer.utils.create
import dev.fyloz.colorrecipesexplorer.utils.excludeAll
import org.slf4j.Logger
import mu.KotlinLogging
import org.springframework.boot.info.BuildProperties
import org.springframework.context.annotation.Lazy
import org.springframework.data.repository.findByIdOrNull
@ -36,9 +36,9 @@ interface ConfigurationSource {
class CompositeConfigurationSource(
@Lazy private val configurationRepository: ConfigurationRepository,
private val properties: CreProperties,
private val buildInfo: BuildProperties,
private val logger: Logger
private val buildInfo: BuildProperties
) : ConfigurationSource {
private val logger = KotlinLogging.logger {}
private val repository by lazy { RepositoryConfigurationSource(configurationRepository) }
private val file by lazy {
FileConfigurationSource("${properties.configDirectory}/$CONFIGURATION_FILE_PATH")

View File

@ -1,4 +1,4 @@
package dev.fyloz.colorrecipesexplorer.service.files
package dev.fyloz.colorrecipesexplorer.logic.files
import dev.fyloz.colorrecipesexplorer.JavaFile
import dev.fyloz.colorrecipesexplorer.utils.File

View File

@ -1,4 +1,4 @@
package dev.fyloz.colorrecipesexplorer.service.files
package dev.fyloz.colorrecipesexplorer.logic.files
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
import dev.fyloz.colorrecipesexplorer.exception.RestException
@ -21,7 +21,7 @@ val BANNED_FILE_PATH_SHARDS = setOf(
"//"
)
interface FileService {
interface FileLogic {
/** Checks if the file at the given [path] exists. */
fun exists(path: String): Boolean
@ -35,7 +35,7 @@ interface FileService {
fun fullPath(path: String): FilePath
}
interface WriteableFileService : FileService {
interface WriteableFileLogic : FileLogic {
/** Creates a file at the given [path]. */
fun create(path: String)
@ -56,10 +56,10 @@ interface WriteableFileService : FileService {
}
@Service
class FileServiceImpl(
class DefaultFileLogic(
private val fileCache: FileCache,
private val creProperties: CreProperties
) : WriteableFileService {
) : WriteableFileLogic {
private val logger = KotlinLogging.logger {}
override fun exists(path: String): Boolean {

View File

@ -1,4 +1,4 @@
package dev.fyloz.colorrecipesexplorer.service.files
package dev.fyloz.colorrecipesexplorer.logic.files
import dev.fyloz.colorrecipesexplorer.utils.FilePath
import org.springframework.core.io.Resource
@ -6,9 +6,9 @@ import org.springframework.core.io.ResourceLoader
import org.springframework.stereotype.Service
@Service
class ResourceFileService(
class ResourceFileLogic(
private val resourceLoader: ResourceLoader
) : FileService {
) : FileLogic {
override fun exists(path: String) =
fullPath(path).resource.exists()

View File

@ -1,6 +1,6 @@
package dev.fyloz.colorrecipesexplorer.service.jobs
package dev.fyloz.colorrecipesexplorer.logic.jobs
import dev.fyloz.colorrecipesexplorer.service.TouchUpKitService
import dev.fyloz.colorrecipesexplorer.logic.TouchUpKitLogic
import mu.KotlinLogging
import org.springframework.context.annotation.Profile
import org.springframework.scheduling.annotation.Scheduled
@ -9,7 +9,7 @@ import org.springframework.stereotype.Component
@Component
@Profile("!emergency")
class TouchUpKitRemover(
private val touchUpKitService: TouchUpKitService
private val touchUpKitLogic: TouchUpKitLogic
) {
private val logger = KotlinLogging.logger {}
@ -20,10 +20,10 @@ class TouchUpKitRemover(
}
private fun removeExpiredKits() {
with(touchUpKitService.getAll().filter(touchUpKitService::isExpired)) {
with(touchUpKitLogic.getAll().filter { it.expired }) {
this.forEach {
logger.debug("Removed expired touch up kit ${it.id} (${it.project} ${it.buggy})")
touchUpKitService.delete(it)
touchUpKitLogic.deleteById(it.id)
}
logger.info("Removed ${this.size} expired touch up kits")
}

View File

@ -0,0 +1,80 @@
package dev.fyloz.colorrecipesexplorer.logic.users
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent
import dev.fyloz.colorrecipesexplorer.config.security.defaultGroupCookieName
import dev.fyloz.colorrecipesexplorer.dtos.GroupDto
import dev.fyloz.colorrecipesexplorer.dtos.UserDto
import dev.fyloz.colorrecipesexplorer.exception.NoDefaultGroupException
import dev.fyloz.colorrecipesexplorer.logic.BaseLogic
import dev.fyloz.colorrecipesexplorer.logic.Logic
import dev.fyloz.colorrecipesexplorer.service.GroupService
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.util.WebUtils
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
const val defaultGroupCookieMaxAge = 10 * 365 * 24 * 60 * 60 // 10 ans
interface GroupLogic : Logic<GroupDto, GroupService> {
/** Gets all the users of the group with the given [id]. */
fun getUsersForGroup(id: Long): Collection<UserDto>
/** Gets the default group from a cookie in the given HTTP [request]. */
fun getRequestDefaultGroup(request: HttpServletRequest): GroupDto
/** Sets the default group cookie for the given HTTP [response]. */
fun setResponseDefaultGroup(id: Long, response: HttpServletResponse)
}
@LogicComponent
class DefaultGroupLogic(service: GroupService, private val userLogic: UserLogic) :
BaseLogic<GroupDto, GroupService>(service, Constants.ModelNames.GROUP),
GroupLogic {
override fun getUsersForGroup(id: Long) = userLogic.getAllByGroup(getById(id))
override fun getRequestDefaultGroup(request: HttpServletRequest): GroupDto {
val defaultGroupCookie = WebUtils.getCookie(request, defaultGroupCookieName)
?: throw NoDefaultGroupException()
val defaultGroupUser = userLogic.getById(
defaultGroupCookie.value.toLong(),
isSystemUser = false,
isDefaultGroupUser = true
)
return defaultGroupUser.group!!
}
override fun setResponseDefaultGroup(id: Long, response: HttpServletResponse) {
val defaultGroupUser = userLogic.getDefaultGroupUser(getById(id))
response.addHeader(
"Set-Cookie",
"$defaultGroupCookieName=${defaultGroupUser.id}; Max-Age=$defaultGroupCookieMaxAge; Path=/api; HttpOnly; Secure; SameSite=strict"
)
}
@Transactional
override fun save(dto: GroupDto): GroupDto {
throwIfNameAlreadyExists(dto.name)
return super.save(dto).also {
userLogic.saveDefaultGroupUser(it)
}
}
override fun update(dto: GroupDto): GroupDto {
throwIfNameAlreadyExists(dto.name, dto.id)
return super.update(dto)
}
override fun deleteById(id: Long) {
userLogic.deleteById(GroupDto.getDefaultGroupUserId(id))
super.deleteById(id)
}
private fun throwIfNameAlreadyExists(name: String, id: Long? = null) {
if (service.existsByName(name, id)) {
throw alreadyExistsException(value = name)
}
}
}

View File

@ -1,12 +1,10 @@
package dev.fyloz.colorrecipesexplorer.service.users
package dev.fyloz.colorrecipesexplorer.logic.users
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
import dev.fyloz.colorrecipesexplorer.model.account.User
import dev.fyloz.colorrecipesexplorer.model.account.UserDetails
import dev.fyloz.colorrecipesexplorer.model.account.UserOutputDto
import dev.fyloz.colorrecipesexplorer.model.account.toOutputDto
import dev.fyloz.colorrecipesexplorer.dtos.UserDetails
import dev.fyloz.colorrecipesexplorer.dtos.UserDto
import dev.fyloz.colorrecipesexplorer.utils.base64encode
import dev.fyloz.colorrecipesexplorer.utils.toDate
import io.jsonwebtoken.Jwts
@ -18,22 +16,22 @@ import java.util.*
const val jwtClaimUser = "user"
interface JwtService {
interface JwtLogic {
/** Build a JWT token for the given [userDetails]. */
fun buildJwt(userDetails: UserDetails): String
/** Build a JWT token for the given [user]. */
fun buildJwt(user: User): String
fun buildJwt(user: UserDto): String
/** Parses a user from the given [jwt] token. */
fun parseJwt(jwt: String): UserOutputDto
fun parseJwt(jwt: String): UserDto
}
@Service
class JwtServiceImpl(
class DefaultJwtLogic(
val objectMapper: ObjectMapper,
val securityProperties: CreSecurityProperties
) : JwtService {
) : JwtLogic {
private val secretKey by lazy {
securityProperties.jwtSecret.base64encode()
}
@ -54,14 +52,14 @@ class JwtServiceImpl(
override fun buildJwt(userDetails: UserDetails) =
buildJwt(userDetails.user)
override fun buildJwt(user: User): String =
override fun buildJwt(user: UserDto): String =
jwtBuilder
.setSubject(user.id.toString())
.setExpiration(getCurrentExpirationDate())
.claim(jwtClaimUser, user.serialize())
.compact()
override fun parseJwt(jwt: String): UserOutputDto =
override fun parseJwt(jwt: String): UserDto =
with(
jwtParser.parseClaimsJws(jwt)
.body.get(jwtClaimUser, String::class.java)
@ -74,6 +72,6 @@ class JwtServiceImpl(
.plusSeconds(securityProperties.jwtDuration)
.toDate()
private fun User.serialize(): String =
objectMapper.writeValueAsString(this.toOutputDto())
private fun UserDto.serialize(): String =
objectMapper.writeValueAsString(this)
}

View File

@ -1,40 +1,41 @@
package dev.fyloz.colorrecipesexplorer.service.users
package dev.fyloz.colorrecipesexplorer.logic.users
import dev.fyloz.colorrecipesexplorer.SpringUserDetails
import dev.fyloz.colorrecipesexplorer.SpringUserDetailsService
import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
import dev.fyloz.colorrecipesexplorer.dtos.UserDetails
import dev.fyloz.colorrecipesexplorer.dtos.UserDto
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import dev.fyloz.colorrecipesexplorer.model.account.Permission
import dev.fyloz.colorrecipesexplorer.model.account.User
import dev.fyloz.colorrecipesexplorer.model.account.UserDetails
import dev.fyloz.colorrecipesexplorer.model.account.user
import org.springframework.context.annotation.Profile
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Service
interface UserDetailsService : SpringUserDetailsService {
interface UserDetailsLogic : SpringUserDetailsService {
/** Loads an [User] for the given [id]. */
fun loadUserById(id: Long, ignoreDefaultGroupUsers: Boolean = false): UserDetails
fun loadUserById(id: Long, isDefaultGroupUser: Boolean = true): UserDetails
}
@Service
@Profile("!emergency")
class UserDetailsServiceImpl(
private val userService: UserService
) : UserDetailsService {
@RequireDatabase
class DefaultUserDetailsLogic(
private val userLogic: UserLogic
) : UserDetailsLogic {
override fun loadUserByUsername(username: String): UserDetails {
try {
return loadUserById(username.toLong(), true)
return loadUserById(username.toLong(), false)
} catch (ex: NotFoundException) {
throw UsernameNotFoundException(username)
}
}
override fun loadUserById(id: Long, ignoreDefaultGroupUsers: Boolean): UserDetails {
val user = userService.getById(
override fun loadUserById(id: Long, isDefaultGroupUser: Boolean): UserDetails {
val user = userLogic.getById(
id,
ignoreDefaultGroupUsers = ignoreDefaultGroupUsers,
ignoreSystemUsers = false
isSystemUser = true,
isDefaultGroupUser = isDefaultGroupUser
)
return UserDetails(user)
}
@ -42,10 +43,10 @@ class UserDetailsServiceImpl(
@Service
@Profile("emergency")
class EmergencyUserDetailsServiceImpl(
class EmergencyUserDetailsLogic(
securityProperties: CreSecurityProperties
) : UserDetailsService {
private val users: Set<User>
) : UserDetailsLogic {
private val users: Set<UserDto>
init {
if (securityProperties.root == null) {
@ -55,20 +56,23 @@ class EmergencyUserDetailsServiceImpl(
users = setOf(
// Add root user
with(securityProperties.root!!) {
user(
UserDto(
id = this.id,
plainPassword = this.password,
permissions = mutableSetOf(Permission.ADMIN)
firstName = "Root",
lastName = "User",
group = null,
password = this.password,
permissions = listOf(Permission.ADMIN)
)
}
)
}
override fun loadUserByUsername(username: String): SpringUserDetails {
return loadUserById(username.toLong(), true)
return loadUserById(username.toLong(), false)
}
override fun loadUserById(id: Long, ignoreDefaultGroupUsers: Boolean): UserDetails {
override fun loadUserById(id: Long, isDefaultGroupUser: Boolean): UserDetails {
val user = users.firstOrNull { it.id == id }
?: throw UsernameNotFoundException(id.toString())

View File

@ -0,0 +1,169 @@
package dev.fyloz.colorrecipesexplorer.logic.users
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent
import dev.fyloz.colorrecipesexplorer.config.security.authorizationCookieName
import dev.fyloz.colorrecipesexplorer.config.security.blacklistedJwtTokens
import dev.fyloz.colorrecipesexplorer.dtos.GroupDto
import dev.fyloz.colorrecipesexplorer.dtos.UserDto
import dev.fyloz.colorrecipesexplorer.dtos.UserSaveDto
import dev.fyloz.colorrecipesexplorer.dtos.UserUpdateDto
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.logic.BaseLogic
import dev.fyloz.colorrecipesexplorer.logic.Logic
import dev.fyloz.colorrecipesexplorer.model.account.Permission
import dev.fyloz.colorrecipesexplorer.service.UserService
import org.springframework.context.annotation.Lazy
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.web.util.WebUtils
import java.time.LocalDateTime
import javax.servlet.http.HttpServletRequest
interface UserLogic : Logic<UserDto, UserService> {
/** Gets all users which have the given [group]. */
fun getAllByGroup(group: GroupDto): Collection<UserDto>
/** Gets the user with the given [id]. */
fun getById(id: Long, isSystemUser: Boolean, isDefaultGroupUser: Boolean): UserDto
/** Gets the default user of the given [group]. */
fun getDefaultGroupUser(group: GroupDto): UserDto
/** Save a default group user for the given [group]. */
fun saveDefaultGroupUser(group: GroupDto)
/** Saves the given [dto]. */
fun save(dto: UserSaveDto): UserDto
/** Updates the given [dto]. */
fun update(dto: UserUpdateDto): UserDto
/** Updates the last login time of the user with the given [id]. */
fun updateLastLoginTime(id: Long, time: LocalDateTime = LocalDateTime.now()): UserDto
/** Updates the password of the user with the given [id]. */
fun updatePassword(id: Long, password: String): UserDto
/** Adds the given [permission] to the user with the given [id]. */
fun addPermission(id: Long, permission: Permission): UserDto
/** Removes the given [permission] from the user with the given [id]. */
fun removePermission(id: Long, permission: Permission): UserDto
/** Logout a user. Add the authorization token of the given [request] to the blacklisted tokens. */
fun logout(request: HttpServletRequest)
}
@LogicComponent
class DefaultUserLogic(
service: UserService, @Lazy private val groupLogic: GroupLogic, @Lazy private val passwordEncoder: PasswordEncoder
) : BaseLogic<UserDto, UserService>(service, Constants.ModelNames.USER), UserLogic {
override fun getAll() = service.getAll(isSystemUser = false, isDefaultGroupUser = false)
override fun getAllByGroup(group: GroupDto) = service.getAllByGroup(group)
override fun getById(id: Long) = getById(id, isSystemUser = false, isDefaultGroupUser = false)
override fun getById(id: Long, isSystemUser: Boolean, isDefaultGroupUser: Boolean) =
service.getById(id, !isDefaultGroupUser, !isSystemUser) ?: throw notFoundException(value = id)
override fun getDefaultGroupUser(group: GroupDto) =
service.getDefaultGroupUser(group) ?: throw notFoundException(identifierName = "groupId", value = group.id)
override fun saveDefaultGroupUser(group: GroupDto) {
save(
UserSaveDto(
id = group.defaultGroupUserId,
firstName = group.name,
lastName = "User",
password = group.name,
groupId = group.id,
permissions = listOf(),
isDefaultGroupUser = true
)
)
}
override fun save(dto: UserSaveDto) = save(
UserDto(
id = dto.id,
firstName = dto.firstName,
lastName = dto.lastName,
password = passwordEncoder.encode(dto.password),
group = if (dto.groupId != null) groupLogic.getById(dto.groupId) else null,
permissions = dto.permissions,
isSystemUser = dto.isSystemUser,
isDefaultGroupUser = dto.isDefaultGroupUser
)
)
override fun save(dto: UserDto): UserDto {
throwIfIdAlreadyExists(dto.id)
throwIfFirstNameAndLastNameAlreadyExists(dto.firstName, dto.lastName)
return super.save(dto)
}
override fun update(dto: UserUpdateDto): UserDto {
val user = getById(dto.id, isSystemUser = false, isDefaultGroupUser = false)
return update(
user.copy(
firstName = dto.firstName,
lastName = dto.lastName,
group = if (dto.groupId != null) groupLogic.getById(dto.groupId) else null,
permissions = dto.permissions
)
)
}
override fun update(dto: UserDto): UserDto {
throwIfFirstNameAndLastNameAlreadyExists(dto.firstName, dto.lastName, dto.id)
return super.update(dto)
}
override fun updateLastLoginTime(id: Long, time: LocalDateTime) = with(getById(id)) {
update(this.copy(lastLoginTime = time))
}
override fun updatePassword(id: Long, password: String) = with(getById(id)) {
update(this.copy(password = passwordEncoder.encode(password)))
}
override fun addPermission(id: Long, permission: Permission) = with(getById(id)) {
update(this.copy(permissions = this.permissions + permission))
}
override fun removePermission(id: Long, permission: Permission) = with(getById(id)) {
update(this.copy(permissions = this.permissions - permission))
}
override fun logout(request: HttpServletRequest) {
val authorizationCookie = WebUtils.getCookie(request, authorizationCookieName)
if (authorizationCookie != null) {
val authorizationToken = authorizationCookie.value
if (authorizationToken != null && authorizationToken.startsWith("Bearer")) {
blacklistedJwtTokens.add(authorizationToken)
}
}
}
private fun throwIfIdAlreadyExists(id: Long) {
if (service.existsById(id)) {
throw alreadyExistsException(identifierName = ID_IDENTIFIER_NAME, value = id)
}
}
private fun throwIfFirstNameAndLastNameAlreadyExists(firstName: String, lastName: String, id: Long? = null) {
if (service.existsByFirstNameAndLastName(firstName, lastName, id)) {
throw AlreadyExistsException(
typeNameLowerCase,
"$typeName already exists",
"A $typeNameLowerCase with the name '$firstName $lastName' already exists",
"$firstName $lastName",
"fullName"
)
}
}
}

View File

@ -1,107 +1,14 @@
package dev.fyloz.colorrecipesexplorer.model
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import dev.fyloz.colorrecipesexplorer.model.validation.NullOrNotBlank
import javax.persistence.*
import javax.validation.constraints.NotBlank
import javax.validation.constraints.NotNull
@Entity
@Table(name = "company")
data class Company(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long?,
override val id: Long,
@Column(unique = true)
override val name: String
) : NamedModel {
override fun toString(): String {
return name
}
}
open class CompanySaveDto(
@field:NotBlank
val name: String
) : EntityDto<Company> {
override fun toEntity(): Company = Company(null, name)
}
open class CompanyUpdateDto(
val id: Long,
@field:NotBlank
val name: String?
) : EntityDto<Company> {
override fun toEntity(): Company = Company(id, name ?: "")
}
// ==== DSL ====
fun company(
id: Long? = null,
name: String = "name",
op: Company.() -> Unit = {}
) = Company(id, name).apply(op)
fun companySaveDto(
name: String = "name",
op: CompanySaveDto.() -> Unit = {}
) = CompanySaveDto(name).apply(op)
fun companyUpdateDto(
id: Long = 0L,
name: String? = "name",
op: CompanyUpdateDto.() -> Unit = {}
) = CompanyUpdateDto(id, name).apply(op)
// ==== Exceptions ====
private const val COMPANY_NOT_FOUND_EXCEPTION_TITLE = "Company not found"
private const val COMPANY_ALREADY_EXISTS_EXCEPTION_TITLE = "Company already exists"
private const val COMPANY_CANNOT_DELETE_EXCEPTION_TITLE = "Cannot delete company"
private const val COMPANY_EXCEPTION_ERROR_CODE = "company"
fun companyIdNotFoundException(id: Long) =
NotFoundException(
COMPANY_EXCEPTION_ERROR_CODE,
COMPANY_NOT_FOUND_EXCEPTION_TITLE,
"A company with the id $id could not be found",
id
)
fun companyNameNotFoundException(name: String) =
NotFoundException(
COMPANY_EXCEPTION_ERROR_CODE,
COMPANY_NOT_FOUND_EXCEPTION_TITLE,
"A company with the name $name could not be found",
name,
"name"
)
fun companyIdAlreadyExistsException(id: Long) =
AlreadyExistsException(
COMPANY_EXCEPTION_ERROR_CODE,
COMPANY_ALREADY_EXISTS_EXCEPTION_TITLE,
"A company with the id $id already exists",
id
)
fun companyNameAlreadyExistsException(name: String) =
AlreadyExistsException(
COMPANY_EXCEPTION_ERROR_CODE,
COMPANY_ALREADY_EXISTS_EXCEPTION_TITLE,
"A company with the name $name already exists",
name,
"name"
)
fun cannotDeleteCompany(company: Company) =
CannotDeleteException(
COMPANY_EXCEPTION_ERROR_CODE,
COMPANY_CANNOT_DELETE_EXCEPTION_TITLE,
"Cannot delete the company ${company.name} because one or more recipes depends on it"
)
) : ModelEntity

View File

@ -1,172 +1,30 @@
package dev.fyloz.colorrecipesexplorer.model
import com.fasterxml.jackson.annotation.JsonIgnore
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import org.springframework.web.multipart.MultipartFile
import dev.fyloz.colorrecipesexplorer.Constants
import javax.persistence.*
import javax.validation.constraints.Min
import javax.validation.constraints.NotBlank
import javax.validation.constraints.Size
const val SIMDUT_FILES_PATH = "pdf/simdut"
@Entity
@Table(name = "material")
data class Material(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long?,
override val id: Long,
@Column(unique = true)
override var name: String,
val name: String,
@Column(name = "inventory_quantity")
var inventoryQuantity: Float,
val inventoryQuantity: Float,
@Column(name = "mix_type")
val isMixType: Boolean,
@ManyToOne
@JoinColumn(name = "material_type_id")
var materialType: MaterialType?
) : NamedModel {
val simdutFilePath
@JsonIgnore
@Transient
get() = "$SIMDUT_FILES_PATH/$name.pdf"
}
open class MaterialSaveDto(
@field:NotBlank
val name: String,
@field:Min(0, message = VALIDATION_SIZE_GE_ZERO)
val inventoryQuantity: Float,
val materialTypeId: Long,
val simdutFile: MultipartFile? = null
) : EntityDto<Material>
open class MaterialUpdateDto(
val id: Long,
@field:NotBlank
val name: String?,
@field:Min(0, message = VALIDATION_SIZE_GE_ZERO)
val inventoryQuantity: Float?,
val materialTypeId: Long?,
val simdutFile: MultipartFile? = null
) : EntityDto<Material>
data class MaterialOutputDto(
override val id: Long,
val name: String,
val inventoryQuantity: Float,
val isMixType: Boolean,
val materialType: MaterialType,
val simdutUrl: String?
) : Model
data class MaterialQuantityDto(
val material: Long,
@field:Min(0, message = VALIDATION_SIZE_GE_ZERO)
val quantity: Float
)
// === DSL ===
fun material(
id: Long? = null,
name: String = "name",
inventoryQuantity: Float = 0f,
isMixType: Boolean = false,
materialType: MaterialType? = materialType(),
op: Material.() -> Unit = {}
) = Material(id, name, inventoryQuantity, isMixType, materialType).apply(op)
fun material(
material: Material,
id: Long? = null,
name: String? = null,
) = Material(
id ?: material.id, name
?: material.name, material.inventoryQuantity, material.isMixType, material.materialType
)
fun materialSaveDto(
name: String = "name",
inventoryQuantity: Float = 0f,
materialTypeId: Long = 0L,
simdutFile: MultipartFile? = null,
op: MaterialSaveDto.() -> Unit = {}
) = MaterialSaveDto(name, inventoryQuantity, materialTypeId, simdutFile).apply(op)
fun materialUpdateDto(
id: Long = 0L,
name: String? = "name",
inventoryQuantity: Float? = 0f,
materialTypeId: Long? = 0L,
simdutFile: MultipartFile? = null,
op: MaterialUpdateDto.() -> Unit = {}
) = MaterialUpdateDto(id, name, inventoryQuantity, materialTypeId, simdutFile).apply(op)
fun materialQuantityDto(
materialId: Long,
quantity: Float,
op: MaterialQuantityDto.() -> Unit = {}
) = MaterialQuantityDto(materialId, quantity).apply(op)
// ==== Exceptions ====
private const
val MATERIAL_NOT_FOUND_EXCEPTION_TITLE = "Material not found"
private const val MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE = "Material already exists"
private const val MATERIAL_CANNOT_DELETE_EXCEPTION_TITLE = "Cannot delete material"
private const val MATERIAL_EXCEPTION_ERROR_CODE = "material"
fun materialIdNotFoundException(id: Long) =
NotFoundException(
MATERIAL_EXCEPTION_ERROR_CODE,
MATERIAL_NOT_FOUND_EXCEPTION_TITLE,
"A material with the id $id could not be found",
id
)
fun materialNameNotFoundException(name: String) =
NotFoundException(
MATERIAL_EXCEPTION_ERROR_CODE,
MATERIAL_NOT_FOUND_EXCEPTION_TITLE,
"A material with the name $name could not be found",
name,
"name"
)
fun materialIdAlreadyExistsException(id: Long) =
AlreadyExistsException(
MATERIAL_EXCEPTION_ERROR_CODE,
MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE,
"A material with the id $id already exists",
id
)
fun materialNameAlreadyExistsException(name: String) =
AlreadyExistsException(
MATERIAL_EXCEPTION_ERROR_CODE,
MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE,
"A material with the name $name already exists",
name,
"name"
)
fun cannotDeleteMaterialException(material: Material) =
CannotDeleteException(
MATERIAL_EXCEPTION_ERROR_CODE,
MATERIAL_CANNOT_DELETE_EXCEPTION_TITLE,
"Cannot delete the material ${material.name} because one or more recipes depends on it"
)
val materialType: MaterialType?
) : ModelEntity {
companion object {
fun getSimdutFilePath(name: String) =
"${Constants.FilePaths.SIMDUT}/$name.pdf"
}
}

View File

@ -1,174 +1,26 @@
package dev.fyloz.colorrecipesexplorer.model
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException
import dev.fyloz.colorrecipesexplorer.exception.CannotUpdateException
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import dev.fyloz.colorrecipesexplorer.model.validation.NullOrNotBlank
import dev.fyloz.colorrecipesexplorer.model.validation.NullOrSize
import org.hibernate.annotations.ColumnDefault
import javax.persistence.*
import javax.validation.constraints.NotBlank
import javax.validation.constraints.NotNull
import javax.validation.constraints.Size
private const val VALIDATION_PREFIX_SIZE = "Must contains exactly 3 characters"
@Entity
@Table(name = "material_type")
data class MaterialType(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long? = null,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long,
@Column(unique = true)
override val name: String = "",
@Column(unique = true)
val name: String = "",
@Column(unique = true)
val prefix: String = "",
@Column(unique = true)
val prefix: String = "",
@Column(name = "use_percentages")
@ColumnDefault("false")
val usePercentages: Boolean = false,
@Column(name = "use_percentages")
@ColumnDefault("false")
val usePercentages: Boolean = false,
@Column(name = "system_type")
@ColumnDefault("false")
val systemType: Boolean = false
) : NamedModel
open class MaterialTypeSaveDto(
@field:NotBlank
val name: String,
@field:NotBlank
@field:Size(min = 3, max = 3, message = VALIDATION_PREFIX_SIZE)
val prefix: String,
val usePercentages: Boolean = false
) : EntityDto<MaterialType> {
override fun toEntity(): MaterialType =
MaterialType(null, name, prefix, usePercentages)
}
open class MaterialTypeUpdateDto(
val id: Long,
@field:NotBlank
val name: String?,
@field:Size(min = 3, max = 3, message = VALIDATION_PREFIX_SIZE)
val prefix: String?
) : EntityDto<MaterialType> {
override fun toEntity(): MaterialType =
MaterialType(id, name ?: "", prefix ?: "")
}
// ==== DSL ====
fun materialType(
id: Long? = null,
name: String = "name",
prefix: String = "PRE",
usePercentages: Boolean = false,
systemType: Boolean = false,
op: MaterialType.() -> Unit = {}
) = MaterialType(id, name, prefix, usePercentages, systemType).apply(op)
fun materialType(
materialType: MaterialType,
newId: Long? = null,
newName: String? = null,
newSystemType: Boolean? = null
) = with(materialType) {
MaterialType(
newId ?: id,
newName ?: name,
prefix,
usePercentages,
newSystemType ?: systemType
)
}
fun materialTypeSaveDto(
name: String = "name",
prefix: String = "PRE",
usePercentages: Boolean = false,
op: MaterialTypeSaveDto.() -> Unit = {}
) = MaterialTypeSaveDto(name, prefix, usePercentages).apply(op)
fun materialTypeUpdateDto(
id: Long = 0L,
name: String? = null,
prefix: String? = null,
op: MaterialTypeUpdateDto.() -> Unit = {}
) = MaterialTypeUpdateDto(id, name, prefix).apply(op)
// ==== Exceptions ====
private const val MATERIAL_TYPE_NOT_FOUND_EXCEPTION_TITLE = "Material type not found"
private const val MATERIAL_TYPE_ALREADY_EXISTS_EXCEPTION_TITLE = "Material type already exists"
private const val MATERIAL_TYPE_CANNOT_DELETE_EXCEPTION_TITLE = "Cannot delete material type"
private const val MATERIAL_TYPE_CANNOT_UPDATE_EXCEPTION_TITLE = "Cannot update material type"
private const val MATERIAL_TYPE_EXCEPTION_ERROR_CODE = "materialtype"
fun materialTypeIdNotFoundException(id: Long) =
NotFoundException(
MATERIAL_TYPE_EXCEPTION_ERROR_CODE,
MATERIAL_TYPE_NOT_FOUND_EXCEPTION_TITLE,
"A material type with the id $id could not be found",
id
)
fun materialTypeNameNotFoundException(name: String) =
NotFoundException(
MATERIAL_TYPE_EXCEPTION_ERROR_CODE,
MATERIAL_TYPE_NOT_FOUND_EXCEPTION_TITLE,
"A material type with the name $name could not be found",
name,
"name"
)
fun materialTypeIdAlreadyExistsException(id: Long) =
AlreadyExistsException(
MATERIAL_TYPE_EXCEPTION_ERROR_CODE,
MATERIAL_TYPE_ALREADY_EXISTS_EXCEPTION_TITLE,
"A material type with the id $id already exists",
id
)
fun materialTypeNameAlreadyExistsException(name: String) =
AlreadyExistsException(
MATERIAL_TYPE_EXCEPTION_ERROR_CODE,
MATERIAL_TYPE_ALREADY_EXISTS_EXCEPTION_TITLE,
"A material type with the name $name already exists",
name,
"name"
)
fun materialTypePrefixAlreadyExistsException(prefix: String) =
AlreadyExistsException(
MATERIAL_TYPE_EXCEPTION_ERROR_CODE,
MATERIAL_TYPE_ALREADY_EXISTS_EXCEPTION_TITLE,
"A material type with the prefix $prefix already exists",
prefix,
"prefix"
)
fun cannotUpdateSystemMaterialTypeException(materialType: MaterialType) =
CannotUpdateException(
MATERIAL_TYPE_EXCEPTION_ERROR_CODE,
MATERIAL_TYPE_CANNOT_UPDATE_EXCEPTION_TITLE,
"Cannot update material type ${materialType.name} because it is a system material type"
)
fun cannotDeleteMaterialTypeException(materialType: MaterialType) =
CannotDeleteException(
MATERIAL_TYPE_EXCEPTION_ERROR_CODE,
MATERIAL_TYPE_CANNOT_DELETE_EXCEPTION_TITLE,
"Cannot delete material type ${materialType.name} because one or more materials depends on it"
)
fun cannotDeleteSystemMaterialTypeException(materialType: MaterialType) =
CannotDeleteException(
MATERIAL_TYPE_EXCEPTION_ERROR_CODE,
MATERIAL_TYPE_CANNOT_DELETE_EXCEPTION_TITLE,
"Cannot delete material type ${materialType.name} because it is a system material type"
)
@Column(name = "system_type")
@ColumnDefault("false")
val systemType: Boolean = false
) : ModelEntity

View File

@ -1,144 +1,24 @@
package dev.fyloz.colorrecipesexplorer.model
import com.fasterxml.jackson.annotation.JsonIgnore
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import javax.persistence.*
import javax.validation.constraints.Min
import javax.validation.constraints.NotBlank
@Entity
@Table(name = "mix")
data class Mix(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long?,
override val id: Long,
var location: String?,
val location: String?,
@JsonIgnore
@ManyToOne
@JoinColumn(name = "recipe_id")
val recipe: Recipe,
@Column(name = "recipe_id")
val recipeId: Long,
@ManyToOne
@JoinColumn(name = "mix_type_id")
var mixType: MixType,
val mixType: MixType,
@OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true)
@JoinColumn(name = "mix_id")
var mixMaterials: MutableSet<MixMaterial>,
) : Model
open class MixSaveDto(
@field:NotBlank
val name: String,
val recipeId: Long,
val materialTypeId: Long,
val mixMaterials: Set<MixMaterialDto>?
) : EntityDto<Mix>
open class MixUpdateDto(
val id: Long,
@field:NotBlank
val name: String?,
val materialTypeId: Long?,
var mixMaterials: Set<MixMaterialDto>?
) : EntityDto<Mix>
data class MixOutputDto(
val id: Long,
val location: String?,
val mixType: MixType,
val mixMaterials: Set<MixMaterialOutputDto>
)
data class MixDeductDto(
val id: Long,
@field:Min(0, message = VALIDATION_SIZE_GE_ZERO)
val ratio: Float
)
data class MixLocationDto(
val mixId: Long,
val location: String?
)
//fun Mix.toOutput() =
// ==== DSL ====
fun mix(
id: Long? = null,
location: String? = "location",
recipe: Recipe = recipe(),
mixType: MixType = mixType(),
mixMaterials: MutableSet<MixMaterial> = mutableSetOf(),
op: Mix.() -> Unit = {}
) = Mix(id, location, recipe, mixType, mixMaterials).apply(op)
fun mixSaveDto(
name: String = "name",
recipeId: Long = 0L,
materialTypeId: Long = 0L,
mixMaterials: Set<MixMaterialDto>? = setOf(),
op: MixSaveDto.() -> Unit = {}
) = MixSaveDto(name, recipeId, materialTypeId, mixMaterials).apply(op)
fun mixUpdateDto(
id: Long = 0L,
name: String? = "name",
materialTypeId: Long? = 0L,
mixMaterials: Set<MixMaterialDto>? = setOf(),
op: MixUpdateDto.() -> Unit = {}
) = MixUpdateDto(id, name, materialTypeId, mixMaterials).apply(op)
fun mixRatio(
id: Long = 0L,
ratio: Float = 1f,
op: MixDeductDto.() -> Unit = {}
) = MixDeductDto(id, ratio).apply(op)
fun mixLocationDto(
mixId: Long = 0L,
location: String? = "location",
op: MixLocationDto.() -> Unit = {}
) = MixLocationDto(mixId, location).apply(op)
// ==== Exceptions ====
private const val MIX_NOT_FOUND_EXCEPTION_TITLE = "Mix not found"
private const val MIX_ALREADY_EXISTS_EXCEPTION_TITLE = "Mix already exists"
private const val MIX_CANNOT_DELETE_EXCEPTION_TITLE = "Cannot delete mix"
private const val MIX_EXCEPTION_ERROR_CODE = "mix"
fun mixIdNotFoundException(id: Long) =
NotFoundException(
MIX_EXCEPTION_ERROR_CODE,
MIX_NOT_FOUND_EXCEPTION_TITLE,
"A mix with the id $id could not be found",
id
)
fun mixIdAlreadyExistsException(id: Long) =
AlreadyExistsException(
MIX_EXCEPTION_ERROR_CODE,
MIX_ALREADY_EXISTS_EXCEPTION_TITLE,
"A mix with the id $id already exists",
id
)
fun cannotDeleteMixException(mix: Mix) =
CannotDeleteException(
MIX_EXCEPTION_ERROR_CODE,
MIX_CANNOT_DELETE_EXCEPTION_TITLE,
"Cannot delete the mix ${mix.mixType.name} because one or more mixes depends on it"
)
val mixMaterials: List<MixMaterial>
) : ModelEntity

View File

@ -1,76 +1,19 @@
package dev.fyloz.colorrecipesexplorer.model
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import javax.persistence.*
import javax.validation.constraints.Min
import javax.validation.constraints.NotNull
@Entity
@Table(name = "mix_material")
data class MixMaterial(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long?,
override val id: Long,
@ManyToOne
@JoinColumn(name = "material_id")
val material: Material,
var quantity: Float,
var position: Int
) : Model
data class MixMaterialDto(
val materialId: Long,
@field:Min(0, message = VALIDATION_SIZE_GE_ZERO)
val quantity: Float,
val position: Int
)
data class MixMaterialOutputDto(
val id: Long,
val material: MaterialOutputDto,
val quantity: Float,
val position: Int
)
// ==== DSL ====
fun mixMaterial(
id: Long? = null,
material: Material = material(),
quantity: Float = 0f,
position: Int = 0,
op: MixMaterial.() -> Unit = {}
) = MixMaterial(id, material, quantity, position).apply(op)
fun mixMaterialDto(
materialId: Long = 0L,
quantity: Float = 0f,
position: Int = 0,
op: MixMaterialDto.() -> Unit = {}
) = MixMaterialDto(materialId, quantity, position).apply(op)
// ==== Exceptions ====
private const val MIX_MATERIAL_NOT_FOUND_EXCEPTION_TITLE = "Mix material not found"
private const val MIX_MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE = "Mix material already exists"
private const val MIX_MATERIAL_EXCEPTION_ERROR_CODE = "mixmaterial"
fun mixMaterialIdNotFoundException(id: Long) =
NotFoundException(
MIX_MATERIAL_EXCEPTION_ERROR_CODE,
MIX_MATERIAL_NOT_FOUND_EXCEPTION_TITLE,
"A mix material with the id $id could not be found",
id
)
fun mixMaterialIdAlreadyExistsException(id: Long) =
AlreadyExistsException(
MIX_MATERIAL_EXCEPTION_ERROR_CODE,
MIX_MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE,
"A mix material with the id $id already exists",
id
)
) : ModelEntity

View File

@ -0,0 +1,19 @@
package dev.fyloz.colorrecipesexplorer.model
import javax.persistence.*
@Entity
@Table(name = "mix_mix_type")
data class MixMixType(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long,
@ManyToOne
@JoinColumn(name = "mix_type_id")
val mixType: MixType,
val quantity: Float,
val position: Int
) : ModelEntity

View File

@ -1,100 +1,21 @@
package dev.fyloz.colorrecipesexplorer.model
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import dev.fyloz.colorrecipesexplorer.exception.RestException
import org.springframework.http.HttpStatus
import javax.persistence.*
@Entity
@Table(name = "mix_type")
data class MixType(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long?,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long,
@Column(unique = true)
override var name: String,
val name: String,
@OneToOne(cascade = [CascadeType.ALL])
@JoinColumn(name = "material_id")
var material: Material
) : NamedModel
@ManyToOne
@JoinColumn(name = "material_type_id")
val materialType: MaterialType,
// ==== DSL ====
fun mixType(
id: Long? = null,
name: String = "name",
material: Material = material(),
op: MixType.() -> Unit = {}
) = MixType(id, name, material).apply(op)
fun mixType(
name: String = "name",
materialType: MaterialType = materialType(),
op: MixType.() -> Unit = {}
) = mixType(
id = null,
name,
material = material(name = name, inventoryQuantity = 0f, isMixType = true, materialType = materialType)
).apply(op)
// ==== Exceptions ====
private const val MIX_TYPE_NOT_FOUND_EXCEPTION_TITLE = "Mix type not found"
private const val MIX_TYPE_ALREADY_EXISTS_EXCEPTION_TITLE = "Mix type already exists"
private const val MIX_TYPE_CANNOT_DELETE_EXCEPTION_TITLE = "Cannot delete mix type"
private const val MIX_TYPE_EXCEPTION_ERROR_CODE = "mixtype"
class MixTypeNameAndMaterialTypeNotFoundException(name: String, materialType: MaterialType) :
RestException(
"notfound-mixtype-namematerialtype",
MIX_TYPE_NOT_FOUND_EXCEPTION_TITLE,
HttpStatus.NOT_FOUND,
"A mix type with the name $name and material type ${materialType.name} could not be found",
mapOf(
"name" to name,
"materialType" to materialType.name
)
)
fun mixTypeIdNotFoundException(id: Long) =
NotFoundException(
MIX_TYPE_EXCEPTION_ERROR_CODE,
MIX_TYPE_NOT_FOUND_EXCEPTION_TITLE,
"A mix type with the id $id could not be found",
id
)
fun mixTypeIdAlreadyExistsException(id: Long) =
AlreadyExistsException(
MIX_TYPE_EXCEPTION_ERROR_CODE,
MIX_TYPE_ALREADY_EXISTS_EXCEPTION_TITLE,
"A mix type with the id $id already exists",
id
)
fun mixTypeNameNotFoundException(name: String) =
NotFoundException(
MIX_TYPE_EXCEPTION_ERROR_CODE,
MIX_TYPE_NOT_FOUND_EXCEPTION_TITLE,
"A mix type with the name $name could not be found",
name,
"name"
)
fun mixTypeNameAlreadyExistsException(name: String) =
AlreadyExistsException(
MIX_TYPE_EXCEPTION_ERROR_CODE,
MIX_TYPE_ALREADY_EXISTS_EXCEPTION_TITLE,
"A mix type with the name $name already exists",
name,
"name"
)
fun cannotDeleteMixTypeException(mixType: MixType) =
CannotDeleteException(
MIX_TYPE_EXCEPTION_ERROR_CODE,
MIX_TYPE_CANNOT_DELETE_EXCEPTION_TITLE,
"Cannot delete the mix type ${mixType.name} because one or more mixes depends on it"
)
@OneToOne(cascade = [CascadeType.ALL])
@JoinColumn(name = "material_id")
val material: Material?
) : ModelEntity

View File

@ -1,22 +0,0 @@
package dev.fyloz.colorrecipesexplorer.model
/** The model of a stored entity. Each model should implements its own equals and hashCode methods to keep compatibility with the legacy Java and Thymeleaf code. */
interface Model {
val id: Long?
}
interface NamedModel : Model {
val name: String
}
interface EntityDto<out E> {
/** Converts the dto to an actual entity. */
fun toEntity(): E {
throw UnsupportedOperationException()
}
}
// GENERAL VALIDATION MESSAGES
const val VALIDATION_SIZE_GE_ZERO = "Must be greater or equals to 0"
const val VALIDATION_SIZE_GE_ONE = "Must be greater or equals to 1"
const val VALIDATION_RANGE_PERCENTS = "Must be between 0 and 100"

View File

@ -0,0 +1,6 @@
package dev.fyloz.colorrecipesexplorer.model
/** Represents an entity with an id, named differently to prevent conflicts with the JPA annotation. */
interface ModelEntity {
val id: Long
}

View File

@ -1,27 +1,15 @@
package dev.fyloz.colorrecipesexplorer.model
import com.fasterxml.jackson.annotation.JsonIgnore
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import dev.fyloz.colorrecipesexplorer.model.account.Group
import dev.fyloz.colorrecipesexplorer.model.account.group
import dev.fyloz.colorrecipesexplorer.rest.FILE_CONTROLLER_PATH
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.time.LocalDate
import javax.persistence.*
import javax.validation.constraints.*
private const val VALIDATION_COLOR_PATTERN = "^#([0-9a-f]{6})$"
const val RECIPE_IMAGES_DIRECTORY = "images/recipes"
@Entity
@Table(name = "recipe")
data class Recipe(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long?,
override val id: Long,
/** The name of the recipe. It is not unique in the entire system, but is unique in the scope of a [Company]. */
val name: String,
@ -46,254 +34,28 @@ data class Recipe(
@JoinColumn(name = "company_id")
val company: Company,
@OneToMany(cascade = [CascadeType.ALL], mappedBy = "recipe")
val mixes: MutableList<Mix>,
@OneToMany(cascade = [CascadeType.ALL], mappedBy = "recipeId")
val mixes: List<Mix>,
@OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true)
@OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true)
@JoinColumn(name = "recipe_id")
val groupsInformation: Set<RecipeGroupInformation>
) : Model {
/** The mix types contained in this recipe. */
val mixTypes: Collection<MixType>
@JsonIgnore
get() = mixes.map { it.mixType }
val imagesDirectoryPath
@JsonIgnore
@Transient
get() = "$RECIPE_IMAGES_DIRECTORY/$id"
fun groupInformationForGroup(groupId: Long) =
groupsInformation.firstOrNull { it.group.id == groupId }
fun imageUrl(deploymentUrl: String, name: String) =
"$deploymentUrl$FILE_CONTROLLER_PATH?path=${
URLEncoder.encode(
"${this.imagesDirectoryPath}/$name",
StandardCharsets.UTF_8
)
}"
}
open class RecipeSaveDto(
@field:NotBlank
val name: String,
@field:NotBlank
val description: String,
@field:NotBlank
@field:Pattern(regexp = VALIDATION_COLOR_PATTERN)
val color: String,
@field:Min(0, message = VALIDATION_RANGE_PERCENTS)
@field:Max(100, message = VALIDATION_RANGE_PERCENTS)
val gloss: Byte,
@field:Min(0, message = VALIDATION_SIZE_GE_ZERO)
val sample: Int?,
val approbationDate: LocalDate?,
val remark: String?,
val companyId: Long
) : EntityDto<Recipe> {
override fun toEntity(): Recipe = recipe(
name = name,
description = description,
sample = sample,
approbationDate = approbationDate,
remark = remark ?: "",
company = company(id = companyId)
)
}
open class RecipeUpdateDto(
val id: Long,
@field:NotBlank
val name: String?,
@field:NotBlank
val description: String?,
@field:NotBlank
@field:Pattern(regexp = VALIDATION_COLOR_PATTERN)
val color: String?,
@field:Min(0, message = VALIDATION_RANGE_PERCENTS)
@field:Max(100, message = VALIDATION_RANGE_PERCENTS)
val gloss: Byte?,
@field:Min(0, message = VALIDATION_SIZE_GE_ZERO)
val sample: Int?,
val approbationDate: LocalDate?,
val remark: String?,
val steps: Set<RecipeStepsDto>?
) : EntityDto<Recipe>
data class RecipeOutputDto(
override val id: Long,
val name: String,
val description: String,
val color: String,
val gloss: Byte,
val sample: Int?,
val approbationDate: LocalDate?,
val approbationExpired: Boolean?,
val remark: String?,
val company: Company,
val mixes: Set<MixOutputDto>,
val groupsInformation: Set<RecipeGroupInformation>,
var imagesUrls: Set<String>
) : Model
val groupsInformation: List<RecipeGroupInformation>
) : ModelEntity
@Entity
@Table(name = "recipe_group_information")
data class RecipeGroupInformation(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long?,
override val id: Long,
@ManyToOne
@JoinColumn(name = "group_id")
val group: Group,
var note: String?,
val note: String?,
@OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true)
@JoinColumn(name = "recipe_group_information_id")
var steps: MutableSet<RecipeStep>?
) : Model
data class RecipeStepsDto(
val groupId: Long,
val steps: Set<RecipeStep>
)
data class RecipePublicDataDto(
val recipeId: Long,
val notes: Set<NoteDto>?,
val mixesLocation: Set<MixLocationDto>?
)
data class NoteDto(
val groupId: Long,
val content: String?
)
// ==== DSL ====
fun recipe(
id: Long? = null,
name: String = "name",
description: String = "description",
color: String = "ffffff",
gloss: Byte = 0,
sample: Int? = -1,
approbationDate: LocalDate? = LocalDate.MIN,
remark: String = "remark",
company: Company = company(),
mixes: MutableList<Mix> = mutableListOf(),
groupsInformation: Set<RecipeGroupInformation> = setOf(),
op: Recipe.() -> Unit = {}
) = Recipe(
id,
name,
description,
color,
gloss,
sample,
approbationDate,
remark,
company,
mixes,
groupsInformation
).apply(op)
fun recipeSaveDto(
name: String = "name",
description: String = "description",
color: String = "ffffff",
gloss: Byte = 0,
sample: Int? = -1,
approbationDate: LocalDate? = LocalDate.MIN,
remark: String = "remark",
companyId: Long = 0L,
op: RecipeSaveDto.() -> Unit = {}
) = RecipeSaveDto(name, description, color, gloss, sample, approbationDate, remark, companyId).apply(op)
fun recipeUpdateDto(
id: Long = 0L,
name: String? = "name",
description: String? = "description",
color: String? = "ffffff",
gloss: Byte? = 0,
sample: Int? = -1,
approbationDate: LocalDate? = LocalDate.MIN,
remark: String? = "remark",
steps: Set<RecipeStepsDto>? = setOf(),
op: RecipeUpdateDto.() -> Unit = {}
) = RecipeUpdateDto(id, name, description, color, gloss, sample, approbationDate, remark, steps).apply(op)
fun recipeGroupInformation(
id: Long? = null,
group: Group = group(),
note: String? = null,
steps: MutableSet<RecipeStep>? = mutableSetOf(),
op: RecipeGroupInformation.() -> Unit = {}
) = RecipeGroupInformation(id, group, note, steps).apply(op)
fun recipePublicDataDto(
recipeId: Long = 0L,
notes: Set<NoteDto>? = null,
mixesLocation: Set<MixLocationDto>? = null,
op: RecipePublicDataDto.() -> Unit = {}
) = RecipePublicDataDto(recipeId, notes, mixesLocation).apply(op)
fun noteDto(
groupId: Long = 0L,
content: String? = "note",
op: NoteDto.() -> Unit = {}
) = NoteDto(groupId, content).apply(op)
// ==== Exceptions ====
private const val RECIPE_NOT_FOUND_EXCEPTION_TITLE = "Recipe not found"
private const val RECIPE_ALREADY_EXISTS_EXCEPTION_TITLE = "Recipe already exists"
private const val RECIPE_EXCEPTION_ERROR_CODE = "recipe"
fun recipeIdNotFoundException(id: Long) =
NotFoundException(
RECIPE_EXCEPTION_ERROR_CODE,
RECIPE_NOT_FOUND_EXCEPTION_TITLE,
"A recipe with the id $id could not be found",
id
)
fun recipeIdAlreadyExistsException(id: Long) =
AlreadyExistsException(
RECIPE_EXCEPTION_ERROR_CODE,
RECIPE_ALREADY_EXISTS_EXCEPTION_TITLE,
"A recipe with the id $id already exists",
id
)
fun recipeNameAlreadyExistsForCompanyException(name: String, company: Company) =
AlreadyExistsException(
"${RECIPE_EXCEPTION_ERROR_CODE}-company",
RECIPE_ALREADY_EXISTS_EXCEPTION_TITLE,
"A recipe with the name $name already exists for the company ${company.name}",
name,
"name",
mutableMapOf(
"company" to company.name,
"companyId" to company.id!!
)
)
val steps: List<RecipeStep>?
) : ModelEntity

View File

@ -1,7 +1,5 @@
package dev.fyloz.colorrecipesexplorer.model
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import javax.persistence.*
@Entity
@ -9,38 +7,9 @@ import javax.persistence.*
data class RecipeStep(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long?,
override val id: Long,
val position: Int,
val message: String
) : Model
// ==== DSL ====
fun recipeStep(
id: Long? = null,
position: Int = 0,
message: String = "message",
op: RecipeStep.() -> Unit = {}
) = RecipeStep(id, position, message).apply(op)
// ==== Exceptions ====
private const val RECIPE_STEP_NOT_FOUND_EXCEPTION_TITLE = "Recipe step not found"
private const val RECIPE_STEP_ALREADY_EXISTS_EXCEPTION_TITLE = "Recipe step already exists"
private const val RECIPE_STEP_EXCEPTION_ERROR_CODE = "recipestep"
fun recipeStepIdNotFoundException(id: Long) =
NotFoundException(
RECIPE_STEP_EXCEPTION_ERROR_CODE,
RECIPE_STEP_NOT_FOUND_EXCEPTION_TITLE,
"A recipe step with the id $id could not be found",
id
)
fun recipeStepIdAlreadyExistsException(id: Long) =
AlreadyExistsException(
RECIPE_STEP_EXCEPTION_ERROR_CODE,
RECIPE_STEP_ALREADY_EXISTS_EXCEPTION_TITLE,
"A recipe step with the id $id already exists",
id
)
) : ModelEntity

View File

@ -1,135 +1,24 @@
package dev.fyloz.colorrecipesexplorer.model.account
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import dev.fyloz.colorrecipesexplorer.exception.RestException
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.model.ModelEntity
import org.hibernate.annotations.Fetch
import org.hibernate.annotations.FetchMode
import org.springframework.http.HttpStatus
import javax.persistence.*
import javax.validation.constraints.NotBlank
import javax.validation.constraints.NotEmpty
import javax.validation.constraints.NotNull
import javax.validation.constraints.Size
@Entity
@Table(name = "user_group")
data class Group(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override var id: Long? = null,
override val id: Long,
@Column(unique = true)
override val name: String = "",
val name: String,
@Enumerated(EnumType.STRING)
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "group_permission", joinColumns = [JoinColumn(name = "group_id")])
@Column(name = "permission")
@Fetch(FetchMode.SUBSELECT)
val permissions: MutableSet<Permission> = mutableSetOf(),
) : NamedModel {
val flatPermissions: Set<Permission>
get() = this.permissions
.flatMap { it.flat() }
.filter { !it.deprecated }
.toSet()
}
open class GroupSaveDto(
@field:NotBlank
val name: String,
@field:NotEmpty
val permissions: MutableSet<Permission>
) : EntityDto<Group> {
override fun toEntity(): Group =
Group(null, name, permissions)
}
open class GroupUpdateDto(
val id: Long,
@field:NotBlank
val name: String,
@field:NotEmpty
val permissions: MutableSet<Permission>
) : EntityDto<Group> {
override fun toEntity(): Group =
Group(id, name, permissions)
}
data class GroupOutputDto(
override val id: Long,
val name: String,
val permissions: Set<Permission>,
val explicitPermissions: Set<Permission>
): Model
fun group(
id: Long? = null,
name: String = "name",
permissions: MutableSet<Permission> = mutableSetOf(),
op: Group.() -> Unit = {}
) = Group(id, name, permissions).apply(op)
fun groupSaveDto(
name: String = "name",
permissions: MutableSet<Permission> = mutableSetOf(),
op: GroupSaveDto.() -> Unit = {}
) = GroupSaveDto(name, permissions).apply(op)
fun groupUpdateDto(
id: Long = 0L,
name: String = "name",
permissions: MutableSet<Permission> = mutableSetOf(),
op: GroupUpdateDto.() -> Unit = {}
) = GroupUpdateDto(id, name, permissions).apply(op)
// ==== Exceptions ====
private const val GROUP_NOT_FOUND_EXCEPTION_TITLE = "Group not found"
private const val GROUP_ALREADY_EXISTS_EXCEPTION_TITLE = "Group already exists"
private const val GROUP_EXCEPTION_ERROR_CODE = "group"
class NoDefaultGroupException : RestException(
"nodefaultgroup",
"No default group",
HttpStatus.NOT_FOUND,
"No default group cookie is defined in the current request"
)
fun groupIdNotFoundException(id: Long) =
NotFoundException(
GROUP_EXCEPTION_ERROR_CODE,
GROUP_NOT_FOUND_EXCEPTION_TITLE,
"A group with the id $id could not be found",
id
)
fun groupNameNotFoundException(name: String) =
NotFoundException(
GROUP_EXCEPTION_ERROR_CODE,
GROUP_NOT_FOUND_EXCEPTION_TITLE,
"A group with the name $name could not be found",
name,
"name"
)
fun groupIdAlreadyExistsException(id: Long) =
AlreadyExistsException(
GROUP_EXCEPTION_ERROR_CODE,
GROUP_ALREADY_EXISTS_EXCEPTION_TITLE,
"A group with the id $id already exists",
id,
)
fun groupNameAlreadyExistsException(name: String) =
AlreadyExistsException(
GROUP_EXCEPTION_ERROR_CODE,
GROUP_ALREADY_EXISTS_EXCEPTION_TITLE,
"A group with the name $name already exists",
name,
"name"
)
val permissions: List<Permission>,
) : ModelEntity

View File

@ -1,20 +1,10 @@
package dev.fyloz.colorrecipesexplorer.model.account
import dev.fyloz.colorrecipesexplorer.SpringUserDetails
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import dev.fyloz.colorrecipesexplorer.model.EntityDto
import dev.fyloz.colorrecipesexplorer.model.Model
import dev.fyloz.colorrecipesexplorer.model.ModelEntity
import org.hibernate.annotations.Fetch
import org.hibernate.annotations.FetchMode
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import java.time.LocalDateTime
import javax.persistence.*
import javax.validation.constraints.NotBlank
import javax.validation.constraints.Size
private const val VALIDATION_PASSWORD_LENGTH = "Must contains at least 8 characters"
@Entity
@Table(name = "user")
@ -23,210 +13,31 @@ data class User(
override val id: Long,
@Column(name = "first_name")
val firstName: String = "",
val firstName: String,
@Column(name = "last_name")
val lastName: String = "",
val lastName: String,
val password: String = "",
val password: String,
@Column(name = "default_group_user")
val isDefaultGroupUser: Boolean = false,
val isDefaultGroupUser: Boolean,
@Column(name = "system_user")
val isSystemUser: Boolean = false,
val isSystemUser: Boolean,
@ManyToOne
@JoinColumn(name = "group_id")
@Fetch(FetchMode.SELECT)
var group: Group? = null,
val group: Group?,
@Enumerated(EnumType.STRING)
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "user_permission", joinColumns = [JoinColumn(name = "user_id")])
@Column(name = "permission")
@Fetch(FetchMode.SUBSELECT)
val permissions: MutableSet<Permission> = mutableSetOf(),
val permissions: List<Permission>,
@Column(name = "last_login_time")
var lastLoginTime: LocalDateTime? = null
) : Model {
val flatPermissions: Set<Permission>
get() = permissions
.flatMap { it.flat() }
.filter { !it.deprecated }
.toMutableSet()
.apply {
if (group != null) this.addAll(group!!.flatPermissions)
}
}
open class UserSaveDto(
val id: Long,
@field:NotBlank
val firstName: String,
@field:NotBlank
val lastName: String,
@field:NotBlank
@field:Size(min = 8, message = VALIDATION_PASSWORD_LENGTH)
val password: String,
val groupId: Long?,
@Enumerated(EnumType.STRING)
val permissions: MutableSet<Permission> = mutableSetOf()
) : EntityDto<User>
open class UserUpdateDto(
val id: Long,
@field:NotBlank
val firstName: String?,
@field:NotBlank
val lastName: String?,
val groupId: Long?,
@Enumerated(EnumType.STRING)
val permissions: Set<Permission>?
) : EntityDto<User>
data class UserOutputDto(
override val id: Long,
val firstName: String,
val lastName: String,
val group: Group?,
val permissions: Set<Permission>,
val explicitPermissions: Set<Permission>,
val lastLoginTime: LocalDateTime?
) : Model
data class UserLoginRequest(val id: Long, val password: String)
data class UserDetails(val user: User) : SpringUserDetails {
override fun getPassword() = user.password
override fun getUsername() = user.id.toString()
override fun getAuthorities() = user.flatPermissions.toAuthorities()
override fun isAccountNonExpired() = true
override fun isAccountNonLocked() = true
override fun isCredentialsNonExpired() = true
override fun isEnabled() = true
}
// ==== DSL ====
fun user(
id: Long = 0L,
firstName: String = "firstName",
lastName: String = "lastName",
password: String = "password",
isDefaultGroupUser: Boolean = false,
isSystemUser: Boolean = false,
group: Group? = null,
permissions: MutableSet<Permission> = mutableSetOf(),
lastLoginTime: LocalDateTime? = null,
op: User.() -> Unit = {}
) = User(
id,
firstName,
lastName,
password,
isDefaultGroupUser,
isSystemUser,
group,
permissions,
lastLoginTime
).apply(op)
fun user(
id: Long = 0L,
firstName: String = "firstName",
lastName: String = "lastName",
plainPassword: String = "password",
isDefaultGroupUser: Boolean = false,
isSystemUser: Boolean = false,
group: Group? = null,
permissions: MutableSet<Permission> = mutableSetOf(),
lastLoginTime: LocalDateTime? = null,
passwordEncoder: PasswordEncoder = BCryptPasswordEncoder(),
op: User.() -> Unit = {}
) = User(
id,
firstName,
lastName,
passwordEncoder.encode(plainPassword),
isDefaultGroupUser,
isSystemUser,
group,
permissions,
lastLoginTime
).apply(op)
fun userSaveDto(
passwordEncoder: PasswordEncoder = BCryptPasswordEncoder(),
id: Long = 0L,
firstName: String = "firstName",
lastName: String = "lastName",
password: String = passwordEncoder.encode("password"),
groupId: Long? = null,
permissions: MutableSet<Permission> = mutableSetOf(),
op: UserSaveDto.() -> Unit = {}
) = UserSaveDto(id, firstName, lastName, password, groupId, permissions).apply(op)
fun userUpdateDto(
id: Long = 0L,
firstName: String = "firstName",
lastName: String = "lastName",
groupId: Long? = null,
permissions: MutableSet<Permission> = mutableSetOf(),
op: UserUpdateDto.() -> Unit = {}
) = UserUpdateDto(id, firstName, lastName, groupId, permissions).apply(op)
// ==== Extensions ====
fun Set<Permission>.toAuthorities() =
this.map { it.toAuthority() }.toMutableSet()
fun User.toOutputDto() =
UserOutputDto(
this.id,
this.firstName,
this.lastName,
this.group,
this.flatPermissions,
this.permissions,
this.lastLoginTime
)
// ==== Exceptions ====
private const val USER_NOT_FOUND_EXCEPTION_TITLE = "User not found"
private const val USER_ALREADY_EXISTS_EXCEPTION_TITLE = "User already exists"
private const val USER_EXCEPTION_ERROR_CODE = "user"
fun userIdNotFoundException(id: Long) =
NotFoundException(
USER_EXCEPTION_ERROR_CODE,
USER_NOT_FOUND_EXCEPTION_TITLE,
"An user with the id $id could not be found",
id
)
fun userIdAlreadyExistsException(id: Long) =
AlreadyExistsException(
USER_EXCEPTION_ERROR_CODE,
USER_ALREADY_EXISTS_EXCEPTION_TITLE,
"An user with the id $id already exists",
id
)
fun userFullNameAlreadyExistsException(firstName: String, lastName: String) =
AlreadyExistsException(
USER_EXCEPTION_ERROR_CODE,
USER_ALREADY_EXISTS_EXCEPTION_TITLE,
"An user with the name '$firstName $lastName' already exists",
"$firstName $lastName",
"fullName"
)
) : ModelEntity

View File

@ -1,24 +1,15 @@
package dev.fyloz.colorrecipesexplorer.model.touchupkit
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import dev.fyloz.colorrecipesexplorer.model.EntityDto
import dev.fyloz.colorrecipesexplorer.model.Model
import dev.fyloz.colorrecipesexplorer.model.VALIDATION_SIZE_GE_ONE
import dev.fyloz.colorrecipesexplorer.model.ModelEntity
import java.time.LocalDate
import javax.persistence.*
import javax.validation.constraints.Min
import javax.validation.constraints.NotBlank
import javax.validation.constraints.NotEmpty
const val TOUCH_UP_KIT_DELIMITER = ';'
@Entity
@Table(name = "touch_up_kit")
data class TouchUpKit(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long?,
override val id: Long,
val project: String,
@ -35,208 +26,28 @@ data class TouchUpKit(
val completionDate: LocalDate?,
@Column(name = "finish")
private val finishConcatenated: String,
val finish: String,
@Column(name = "material")
private val materialConcatenated: String,
val material: String,
@OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true)
@JoinColumn(name = "touch_up_kit_id")
val content: Set<TouchUpKitProduct>
) : Model {
val finish
get() = finishConcatenated.split(TOUCH_UP_KIT_DELIMITER)
val material
get() = materialConcatenated.split(TOUCH_UP_KIT_DELIMITER)
val completed
get() = completionDate != null
}
val content: List<TouchUpKitProduct>
) : ModelEntity
@Entity
@Table(name = "touch_up_kit_product")
data class TouchUpKitProduct(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long?,
val name: String,
val description: String?,
val quantity: Float,
val ready: Boolean
) : Model
data class TouchUpKitSaveDto(
@field:NotBlank
val project: String,
@field:NotBlank
val buggy: String,
@field:NotBlank
val company: String,
@field:Min(1, message = VALIDATION_SIZE_GE_ONE)
val quantity: Int,
val shippingDate: LocalDate,
@field:NotEmpty
val finish: List<String>,
@field:NotEmpty
val material: List<String>,
@field:NotEmpty
val content: Set<TouchUpKitProductDto>
) : EntityDto<TouchUpKit> {
override fun toEntity() = touchUpKit(this)
}
data class TouchUpKitUpdateDto(
val id: Long,
@field:NotBlank
val project: String?,
@field:NotBlank
val buggy: String?,
@field:NotBlank
val company: String?,
@field:Min(1, message = VALIDATION_SIZE_GE_ONE)
val quantity: Int?,
val shippingDate: LocalDate?,
val completionDate: LocalDate?,
@field:NotEmpty
val finish: List<String>?,
@field:NotEmpty
val material: List<String>?,
@field:NotEmpty
val content: Set<TouchUpKitProductDto>?
) : EntityDto<TouchUpKit>
data class TouchUpKitOutputDto(
override val id: Long,
val project: String,
val buggy: String,
val company: String,
val quantity: Int,
val shippingDate: LocalDate,
val completed: Boolean,
val completionDate: LocalDate?,
val expired: Boolean,
val finish: List<String>,
val material: List<String>,
val content: Set<TouchUpKitProduct>,
val pdfUrl: String
) : Model
data class TouchUpKitProductDto(
val name: String,
val description: String?,
val quantity: Float,
val ready: Boolean
)
// ==== DSL ====
fun touchUpKit(
id: Long? = null,
project: String = "project",
buggy: String = "buggy",
company: String = "company",
quantity: Int = 1,
shippingDate: LocalDate = LocalDate.now(),
completionDate: LocalDate? = null,
finish: List<String>,
material: List<String>,
content: Set<TouchUpKitProduct>,
op: TouchUpKit.() -> Unit = {}
) = TouchUpKit(
id,
project,
buggy,
company,
quantity,
shippingDate,
completionDate,
finish.reduce { acc, f -> "$acc$TOUCH_UP_KIT_DELIMITER$f" },
material.reduce { acc, f -> "$acc$TOUCH_UP_KIT_DELIMITER$f" },
content
).apply(op)
fun touchUpKit(touchUpKitSaveDto: TouchUpKitSaveDto) =
with(touchUpKitSaveDto) {
touchUpKit(
project = project,
buggy = buggy,
company = company,
quantity = quantity,
shippingDate = shippingDate,
finish = finish,
material = material,
content = content.map { touchUpKitProduct(it) }.toSet()
)
}
fun touchUpKitProduct(
id: Long? = null,
name: String = "product",
description: String? = "description",
quantity: Float = 1f,
ready: Boolean = false,
op: TouchUpKitProduct.() -> Unit = {}
) = TouchUpKitProduct(id, name, description, quantity, ready)
.apply(op)
fun touchUpKitUpdateDto(
id: Long = 0L,
project: String? = null,
buggy: String? = null,
company: String? = null,
quantity: Int? = null,
shippingDate: LocalDate? = null,
completionDate: LocalDate? = null,
finish: List<String>? = null,
material: List<String>? = null,
content: Set<TouchUpKitProductDto>? = null
) = TouchUpKitUpdateDto(id, project, buggy, company, quantity, shippingDate, completionDate, finish, material, content)
fun touchUpKitProduct(touchUpKitProductDto: TouchUpKitProductDto) =
touchUpKitProduct(
name = touchUpKitProductDto.name,
description = touchUpKitProductDto.description,
quantity = touchUpKitProductDto.quantity,
ready = touchUpKitProductDto.ready
)
// ==== Exceptions ====
private const val TOUCH_UP_KIT_NOT_FOUND_EXCEPTION_TITLE = "Touch up kit not found"
private const val TOUCH_UP_KIT_ALREADY_EXISTS_EXCEPTION_TITLE = "Touch up kit already exists"
private const val TOUCH_UP_KIT_EXCEPTION_ERROR_CODE = "touchupkit"
fun touchUpKitIdNotFoundException(id: Long) =
NotFoundException(
TOUCH_UP_KIT_EXCEPTION_ERROR_CODE,
TOUCH_UP_KIT_NOT_FOUND_EXCEPTION_TITLE,
"A touch up kit with the id $id could not be found",
id
)
fun touchUpKitIdAlreadyExistsException(id: Long) =
AlreadyExistsException(
TOUCH_UP_KIT_EXCEPTION_ERROR_CODE,
TOUCH_UP_KIT_ALREADY_EXISTS_EXCEPTION_TITLE,
"A touch up kit with the id $id already exists",
id
)
) : ModelEntity

View File

@ -1,45 +0,0 @@
package dev.fyloz.colorrecipesexplorer.model.validation
import javax.validation.Constraint
import javax.validation.ConstraintValidator
import javax.validation.ConstraintValidatorContext
import javax.validation.Payload
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
import kotlin.reflect.KClass
private const val MESSAGE = "must be null or not blank"
@Target(AnnotationTarget.FIELD)
@MustBeDocumented
@Constraint(validatedBy = [NullOrNotBlankValidator::class])
annotation class NullOrNotBlank(
val message: String = MESSAGE,
val groups: Array<KClass<*>> = [],
@Suppress("unused") val payload: Array<KClass<out Payload>> = []
)
class NullOrNotBlankValidator : ConstraintValidator<NullOrNotBlank, String> {
var message = MESSAGE
override fun initialize(constraintAnnotation: NullOrNotBlank) {
message = constraintAnnotation.message
}
override fun isValid(value: String?, context: ConstraintValidatorContext): Boolean {
return value.isNullOrNotBlank().apply {
if (!this) context.buildConstraintViolationWithTemplate(message)
}
}
}
fun String?.isNullOrNotBlank(): Boolean = this == null || isNotBlank()
/** Checks if the given string [value] is not null and not blank. */
@ExperimentalContracts
fun isNotNullAndNotBlank(value: String?): Boolean {
contract { returns(true) implies (value != null) }
return value != null && value.isNotBlank()
}
infix fun String?.or(alternative: String): String = if (isNotNullAndNotBlank(this)) this else alternative

View File

@ -1,46 +0,0 @@
package dev.fyloz.colorrecipesexplorer.model.validation
import javax.validation.Constraint
import javax.validation.ConstraintValidator
import javax.validation.ConstraintValidatorContext
import javax.validation.Payload
import kotlin.reflect.KClass
private const val MIN_SIZE = Long.MIN_VALUE
private const val MAX_SIZE = Long.MAX_VALUE
private const val MESSAGE = "must be null or have a correct length"
@Target(AnnotationTarget.FIELD)
@MustBeDocumented
@Constraint(validatedBy = [NullOrSizeValidator::class])
annotation class NullOrSize(
val min: Long = MIN_SIZE,
val max: Long = MAX_SIZE,
val message: String = MESSAGE,
val groups: Array<KClass<*>> = [],
@Suppress("unused") val payload: Array<KClass<out Payload>> = []
)
class NullOrSizeValidator : ConstraintValidator<NullOrSize, Any> {
var min = MIN_SIZE
var max = MAX_SIZE
var message = MESSAGE
override fun initialize(constraintAnnotation: NullOrSize) {
min = constraintAnnotation.min
max = constraintAnnotation.max
message = constraintAnnotation.message
}
override fun isValid(value: Any?, context: ConstraintValidatorContext): Boolean {
if (value == null) return true
return when (value) {
is Number -> value.toLong() in min..max
is String -> value.length in min..max
is Collection<*> -> value.size in min..max
else -> throw IllegalStateException("Cannot use @NullOrSize on type ${value::class}")
}.apply {
if (!this) context.buildConstraintViolationWithTemplate(message)
}
}
}

View File

@ -3,18 +3,28 @@ package dev.fyloz.colorrecipesexplorer.repository
import dev.fyloz.colorrecipesexplorer.model.account.Group
import dev.fyloz.colorrecipesexplorer.model.account.User
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
@Repository
interface UserRepository : JpaRepository<User, Long> {
fun existsByFirstNameAndLastName(firstName: String, lastName: String): Boolean
fun findByFirstNameAndLastName(firstName: String, lastName: String): User?
/** Checks if a user with the given [firstName], [lastName] and a different [id] exists. */
fun existsByFirstNameAndLastNameAndIdNot(firstName: String, lastName: String, id: Long): Boolean
/** Finds all users for the given [group]. */
@Query("SELECT u FROM User u WHERE u.group = :group AND u.isSystemUser IS FALSE AND u.isDefaultGroupUser IS FALSE")
fun findAllByGroup(group: Group): Collection<User>
fun findByIsDefaultGroupUserIsTrueAndGroupIs(group: Group): User
/** Finds the user with the given [firstName] and [lastName]. */
fun findByFirstNameAndLastName(firstName: String, lastName: String): User?
/** Finds the default user for the given [group]. */
@Query("SELECT u FROM User u WHERE u.group = :group AND u.isDefaultGroupUser IS TRUE")
fun findDefaultGroupUser(group: Group): User?
}
@Repository
interface GroupRepository : NamedJpaRepository<Group>
interface GroupRepository : JpaRepository<Group, Long> {
/** Checks if a group with the given [name] and a different [id] exists. */
fun existsByNameAndIdNot(name: String, id: Long): Boolean
}

View File

@ -1,18 +1,21 @@
package dev.fyloz.colorrecipesexplorer.repository
import dev.fyloz.colorrecipesexplorer.model.Company
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
@Repository
interface CompanyRepository : NamedJpaRepository<Company> {
interface CompanyRepository : JpaRepository<Company, Long> {
/** Checks if a company with the given [name] and a different [id] exists. */
fun existsByNameAndIdNot(name: String, id: Long): Boolean
/** Checks if a recipe depends on the company with the given [id]. */
@Query(
"""
select case when(count(r.id) > 0) then false else true end
from Company c
left join Recipe r on c.id = r.company.id
where c.id = :id
"""
select case when(count(r) > 0) then true else false end
from Recipe r where r.company.id = :id
"""
)
fun canBeDeleted(id: Long): Boolean
fun isUsedByRecipe(id: Long): Boolean
}

View File

@ -1,29 +1,33 @@
package dev.fyloz.colorrecipesexplorer.repository
import dev.fyloz.colorrecipesexplorer.model.Material
import dev.fyloz.colorrecipesexplorer.model.MaterialType
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
@Repository
interface MaterialRepository : NamedJpaRepository<Material> {
/** Checks if one or more materials have the given [materialType]. */
fun existsByMaterialType(materialType: MaterialType): Boolean
interface MaterialRepository : JpaRepository<Material, Long> {
/** Checks if a material with the given [name] and a different [id] exists. */
fun existsByNameAndIdNot(name: String, id: Long): Boolean
/** Gets all non mix type materials. */
fun findAllByIsMixTypeIsFalse(): Collection<Material>
/** Updates the [inventoryQuantity] of the [Material] with the given [id]. */
@Modifying
@Query("UPDATE Material m SET m.inventoryQuantity = :inventoryQuantity WHERE m.id = :id")
fun updateInventoryQuantityById(id: Long, inventoryQuantity: Float)
/** Checks if a mix material or a mix type depends on the material with the given [id]. */
@Query(
"""
"""
select case when(count(mm.id) + count(mt.id) > 0) then false else true end
from Material m
left join MixMaterial mm on m.id = mm.material.id
left join MixType mt on m.id = mt.material.id
left join MixMaterial mm on mm.material.id = m.id
left join MixType mt on mt.material.id = m.id
where m.id = :id
"""
"""
)
fun canBeDeleted(id: Long): Boolean
fun isUsedByMixMaterialOrMixType(id: Long): Boolean
}

View File

@ -1,30 +1,33 @@
package dev.fyloz.colorrecipesexplorer.repository
import dev.fyloz.colorrecipesexplorer.model.MaterialType
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
@Repository
interface MaterialTypeRepository : NamedJpaRepository<MaterialType> {
/** Checks if a material type exists with the given [prefix]. */
fun existsByPrefix(prefix: String): Boolean
interface MaterialTypeRepository : JpaRepository<MaterialType, Long> {
/** Checks if a system material type with the given [id] exists. */
fun existsByIdAndSystemTypeIsTrue(id: Long): Boolean
fun existsByIdAndSystemTypeIs(id: Long, systemType: Boolean): Boolean
/** Gets all material types which are not system types. */
fun findAllBySystemTypeIs(value: Boolean): Collection<MaterialType>
/** Checks if a material type with the given [name] and a different [id] exists. */
fun existsByNameAndIdNot(name: String, id: Long): Boolean
/** Gets the material type with the given [prefix]. */
fun findByPrefix(prefix: String): MaterialType?
/** Checks if a material type with the given [prefix] and a different [id] exists. */
fun existsByPrefixAndIdNot(prefix: String, id: Long): Boolean
/** Find all material types which are or not [systemType]s. */
fun findAllBySystemTypeIs(systemType: Boolean): Collection<MaterialType>
/** Find the material type with the given [name]. */
fun findByName(name: String): MaterialType?
/** Checks if a material depends on the material type with the given [id]. */
@Query(
"""
select case when(count(m.id) > 0) then false else true end
from MaterialType t
left join Material m on t.id = m.materialType.id
where t.id = :id
"""
"""
select case when(count(m) > 0) then true else false end
from Material m where m.materialType.id = :id
"""
)
fun canBeDeleted(id: Long): Boolean
fun isUsedByMaterial(id: Long): Boolean
}

View File

@ -1,12 +1,12 @@
package dev.fyloz.colorrecipesexplorer.repository
import dev.fyloz.colorrecipesexplorer.model.Material
import dev.fyloz.colorrecipesexplorer.model.MixMaterial
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
@Repository
interface MixMaterialRepository : JpaRepository<MixMaterial, Long> {
/** Checks if one or more mix materials have the given [material]. */
fun existsByMaterial(material: Material): Boolean
/** Checks if one or more mix materials have the given [materialId]. */
fun existsByMaterialId(materialId: Long): Boolean
}

View File

@ -0,0 +1,34 @@
package dev.fyloz.colorrecipesexplorer.repository
import dev.fyloz.colorrecipesexplorer.model.MixMixType
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
@Repository
interface MixMixTypeRepository : JpaRepository<MixMixType, Long> {
@Query(
nativeQuery = true, value = """
SELECT * FROM mix_mix_type mmt WHERE mmt.mix_id = :mixId
"""
)
fun findAllByMixId(mixId: Long): List<MixMixType>
@Modifying
@Query(
nativeQuery = true, value = """
INSERT INTO mix_mix_type (id, mix_type_id, mix_id, quantity, position)
VALUES (:id, :mixTypeId, :mixId, :quantity, :position)
"""
)
fun saveForMixId(id: Long?, mixTypeId: Long, mixId: Long, quantity: Float, position: Int)
@Modifying
@Query(
nativeQuery = true, value = """
DELETE FROM mix_mix_type mmt WHERE mmt.mix_id = :mixId
"""
)
fun deleteAllByMixId(mixId: Long)
}

View File

@ -7,21 +7,11 @@ import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
interface MixRepository : JpaRepository<Mix, Long> {
/** Finds all mixes with the given [mixType]. */
fun findAllByMixType(mixType: MixType): Collection<Mix>
/** Finds all mixes with the mix type with the given [mixTypeId]. */
fun findAllByMixTypeId(mixTypeId: Long): Collection<Mix>
/** Updates the [location] of the [Mix] with the given [id]. */
@Modifying
@Query("UPDATE Mix m SET m.location = :location WHERE m.id = :id")
fun updateLocationById(id: Long, location: String?)
@Query(
"""
select case when(count(mm.id) > 0) then false else true end
from Mix m
left join MixMaterial mm on m.mixType.material.id = mm.material.id
where m.id = :id
"""
)
fun canBeDeleted(id: Long): Boolean
}

View File

@ -1,30 +1,41 @@
package dev.fyloz.colorrecipesexplorer.repository
import dev.fyloz.colorrecipesexplorer.model.Material
import dev.fyloz.colorrecipesexplorer.model.MaterialType
import dev.fyloz.colorrecipesexplorer.model.MixType
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
@Repository
interface MixTypeRepository : NamedJpaRepository<MixType> {
@Query("select case when(count(m) > 0) then true else false end from MixType m where m.name = :name and m.material.materialType = :materialType")
fun existsByNameAndMaterialType(name: String, materialType: MaterialType): Boolean
/** Gets the mix type with the given [material]. */
fun findByMaterial(material: Material): MixType?
/** Gets the [MixType] with the given [name] and [materialType]. */
@Query("select m from MixType m where m.name = :name and m.material.materialType = :materialType")
fun findByNameAndMaterialType(name: String, materialType: MaterialType): MixType?
interface MixTypeRepository : JpaRepository<MixType, Long> {
/** Checks if a mix type with the given [name], [materialTypeId] and a different [id] exists. */
@Query(
"""
select case when(count(m.id) > 0) then false else true end
from MixType t
left join Mix m on t.id = m.mixType.id
where t.id = :id
"""
SELECT CASE WHEN(COUNT(mt.id)) > 1 THEN TRUE ELSE FALSE END
FROM MixType mt
WHERE mt.name = :name AND mt.materialType.id = :materialTypeId AND mt.id <> :id
"""
)
fun canBeDeleted(id: Long): Boolean
fun existsByNameAndMaterialTypeAndIdNot(name: String, materialTypeId: Long, id: Long): Boolean
/** Finds the mix type with the given [name] and [materialTypeId]. */
@Query("SELECT m FROM MixType m WHERE m.name = :name AND m.material.materialType.id = :materialTypeId")
fun findByNameAndMaterialType(name: String, materialTypeId: Long): MixType?
/** Checks if a mix depends on the mix type with the given [id]. */
@Query(
"""
select case when(count(m.id) > 0) then false else true end
from Mix m where m.mixType.id = :id
"""
)
fun isUsedByMixes(id: Long): Boolean
/** Checks if the mix type with the given [id] is used by more than one mix. */
@Query(
"""
select case when(count(m.id) > 1) then false else true end
from Mix m where m.mixType.id = :id
"""
)
fun isShared(id: Long): Boolean
}

View File

@ -1,19 +1,19 @@
package dev.fyloz.colorrecipesexplorer.repository
import dev.fyloz.colorrecipesexplorer.model.Company
import dev.fyloz.colorrecipesexplorer.model.Recipe
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
interface RecipeRepository : JpaRepository<Recipe, Long> {
/** Checks if one or more recipes have the given [company]. */
fun existsByCompany(company: Company): Boolean
/** Checks if a recipe exists with the given [name] and [company]. */
fun existsByNameAndCompany(name: String, company: Company): Boolean
/** Checks if a recipe with the given [name], [companyId] and a different [id] exists. */
@Query(
"""
SELECT CASE WHEN(COUNT(r) > 0) THEN TRUE ELSE FALSE END
FROM Recipe r WHERE r.name = :name AND r.company.id = :companyId AND r.id <> :id
"""
)
fun existsByNameAndCompanyAndIdNot(name: String, companyId: Long, id: Long): Boolean
/** Gets all recipes with the given [name]. */
fun findAllByName(name: String): Collection<Recipe>
/** Gets all recipes with the given [company]. */
fun findAllByCompany(company: Company): Collection<Recipe>
}

View File

@ -1,18 +0,0 @@
package dev.fyloz.colorrecipesexplorer.repository
import dev.fyloz.colorrecipesexplorer.model.NamedModel
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.repository.NoRepositoryBean
/** Adds support for entities using a name identifier. */
@NoRepositoryBean
interface NamedJpaRepository<E : NamedModel> : JpaRepository<E, Long> {
/** Checks if an entity with the given [name]. */
fun existsByName(name: String): Boolean
/** Gets the entity with the given [name]. */
fun findByName(name: String): E?
/** Removes the entity with the given [name]. */
fun deleteByName(name: String)
}

View File

@ -2,5 +2,13 @@ package dev.fyloz.colorrecipesexplorer.repository
import dev.fyloz.colorrecipesexplorer.model.touchupkit.TouchUpKit
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import java.time.LocalDate
interface TouchUpKitRepository : JpaRepository<TouchUpKit, Long>
interface TouchUpKitRepository : JpaRepository<TouchUpKit, Long> {
/** Updates the [completionDate] of the touch up kit with the given [id]. */
@Modifying
@Query("UPDATE TouchUpKit t SET t.completionDate = :completionDate WHERE t.id = :id")
fun updateCompletionDateById(id: Long, completionDate: LocalDate)
}

View File

@ -1,11 +1,15 @@
package dev.fyloz.colorrecipesexplorer.rest
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeEditUsers
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewUsers
import dev.fyloz.colorrecipesexplorer.model.account.*
import dev.fyloz.colorrecipesexplorer.service.users.GroupService
import dev.fyloz.colorrecipesexplorer.service.users.UserService
import mu.KotlinLogging
import dev.fyloz.colorrecipesexplorer.dtos.GroupDto
import dev.fyloz.colorrecipesexplorer.dtos.UserDto
import dev.fyloz.colorrecipesexplorer.dtos.UserSaveDto
import dev.fyloz.colorrecipesexplorer.dtos.UserUpdateDto
import dev.fyloz.colorrecipesexplorer.logic.users.GroupLogic
import dev.fyloz.colorrecipesexplorer.logic.users.UserLogic
import dev.fyloz.colorrecipesexplorer.model.account.Permission
import org.springframework.context.annotation.Profile
import org.springframework.http.MediaType
import org.springframework.security.access.prepost.PreAuthorize
@ -14,44 +18,39 @@ import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
import javax.validation.Valid
private const val USER_CONTROLLER_PATH = "api/user"
private const val GROUP_CONTROLLER_PATH = "api/user/group"
@RestController
@RequestMapping(USER_CONTROLLER_PATH)
@RequestMapping(Constants.ControllerPaths.USER)
@Profile("!emergency")
class UserController(private val userService: UserService) {
class UserController(private val userLogic: UserLogic) {
@GetMapping
@PreAuthorizeViewUsers
fun getAll() =
ok(userService.getAllForOutput())
ok(userLogic.getAll())
@GetMapping("{id}")
@PreAuthorizeViewUsers
fun getById(@PathVariable id: Long) =
ok(userService.getByIdForOutput(id))
ok(userLogic.getById(id))
@PostMapping
@PreAuthorizeEditUsers
fun save(@Valid @RequestBody user: UserSaveDto) =
created<UserOutputDto>(USER_CONTROLLER_PATH) {
with(userService) {
save(user).toOutput()
}
created<UserDto>(Constants.ControllerPaths.USER) {
userLogic.save(user)
}
@PutMapping
@PreAuthorizeEditUsers
fun update(@Valid @RequestBody user: UserUpdateDto) =
noContent {
userService.update(user)
userLogic.update(user)
}
@PutMapping("{id}/password", consumes = [MediaType.TEXT_PLAIN_VALUE])
@PreAuthorizeEditUsers
fun updatePassword(@PathVariable id: Long, @RequestBody password: String) =
noContent {
userService.updatePassword(id, password)
userLogic.updatePassword(id, password)
}
@PutMapping("{userId}/permissions/{permission}")
@ -60,7 +59,7 @@ class UserController(private val userService: UserService) {
@PathVariable userId: Long,
@PathVariable permission: Permission
) = noContent {
userService.addPermission(userId, permission)
userLogic.addPermission(userId, permission)
}
@DeleteMapping("{userId}/permissions/{permission}")
@ -69,92 +68,87 @@ class UserController(private val userService: UserService) {
@PathVariable userId: Long,
@PathVariable permission: Permission
) = noContent {
userService.removePermission(userId, permission)
userLogic.removePermission(userId, permission)
}
@DeleteMapping("{id}")
@PreAuthorizeEditUsers
fun deleteById(@PathVariable id: Long) =
userService.deleteById(id)
userLogic.deleteById(id)
}
@RestController
@RequestMapping(GROUP_CONTROLLER_PATH)
@RequestMapping(Constants.ControllerPaths.GROUP)
@Profile("!emergency")
class GroupsController(
private val groupService: GroupService,
private val userService: UserService
private val groupLogic: GroupLogic,
private val userLogic: UserLogic
) {
@GetMapping
@PreAuthorize("hasAnyAuthority('VIEW_RECIPES', 'VIEW_USERS')")
fun getAll() =
ok(groupService.getAllForOutput())
ok(groupLogic.getAll())
@GetMapping("{id}")
@PreAuthorizeViewUsers
fun getById(@PathVariable id: Long) =
ok(groupService.getByIdForOutput(id))
ok(groupLogic.getById(id))
@GetMapping("{id}/users")
@PreAuthorizeViewUsers
fun getUsersForGroup(@PathVariable id: Long) =
ok(with(userService) {
groupService.getUsersForGroup(id)
.map { it.toOutput() }
})
ok(groupLogic.getUsersForGroup(id))
@PostMapping("default/{groupId}")
@PreAuthorizeViewUsers
fun setDefaultGroup(@PathVariable groupId: Long, response: HttpServletResponse) =
noContent {
groupService.setResponseDefaultGroup(groupId, response)
groupLogic.setResponseDefaultGroup(groupId, response)
}
@GetMapping("default")
@PreAuthorizeViewUsers
fun getRequestDefaultGroup(request: HttpServletRequest) =
ok(with(groupService) {
getRequestDefaultGroup(request).toOutput()
ok(with(groupLogic) {
getRequestDefaultGroup(request)
})
@GetMapping("currentuser")
fun getCurrentGroupUser(request: HttpServletRequest) =
ok(with(groupService.getRequestDefaultGroup(request)) {
userService.getDefaultGroupUser(this).toOutputDto()
ok(with(groupLogic.getRequestDefaultGroup(request)) {
userLogic.getDefaultGroupUser(this)
})
@PostMapping
@PreAuthorizeEditUsers
fun save(@Valid @RequestBody group: GroupSaveDto) =
created<GroupOutputDto>(GROUP_CONTROLLER_PATH) {
with(groupService) {
save(group).toOutput()
}
fun save(@Valid @RequestBody group: GroupDto) =
created<GroupDto>(Constants.ControllerPaths.GROUP) {
groupLogic.save(group)
}
@PutMapping
@PreAuthorizeEditUsers
fun update(@Valid @RequestBody group: GroupUpdateDto) =
fun update(@Valid @RequestBody group: GroupDto) =
noContent {
groupService.update(group)
groupLogic.update(group)
}
@DeleteMapping("{id}")
@PreAuthorizeEditUsers
fun deleteById(@PathVariable id: Long) =
noContent {
groupService.deleteById(id)
groupLogic.deleteById(id)
}
}
@RestController
@RequestMapping("api")
@Profile("!emergency")
class LogoutController(private val userService: UserService) {
class LogoutController(private val userLogic: UserLogic) {
@GetMapping("logout")
@PreAuthorize("isFullyAuthenticated()")
fun logout(request: HttpServletRequest) =
ok {
userService.logout(request)
userLogic.logout(request)
}
}

View File

@ -1,48 +1,45 @@
package dev.fyloz.colorrecipesexplorer.rest
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewCatalog
import dev.fyloz.colorrecipesexplorer.model.Company
import dev.fyloz.colorrecipesexplorer.model.CompanySaveDto
import dev.fyloz.colorrecipesexplorer.model.CompanyUpdateDto
import dev.fyloz.colorrecipesexplorer.service.CompanyService
import org.springframework.context.annotation.Profile
import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase
import dev.fyloz.colorrecipesexplorer.dtos.CompanyDto
import dev.fyloz.colorrecipesexplorer.logic.CompanyLogic
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.*
import javax.validation.Valid
private const val COMPANY_CONTROLLER_PATH = "api/company"
@RestController
@RequestMapping(COMPANY_CONTROLLER_PATH)
@Profile("!emergency")
@RequestMapping(Constants.ControllerPaths.COMPANY)
@RequireDatabase
@PreAuthorizeViewCatalog
class CompanyController(private val companyService: CompanyService) {
class CompanyController(private val companyLogic: CompanyLogic) {
@GetMapping
fun getAll() =
ok(companyService.getAllForOutput())
ok(companyLogic.getAll())
@GetMapping("{id}")
fun getById(@PathVariable id: Long) =
ok(companyService.getByIdForOutput(id))
ok(companyLogic.getById(id))
@PostMapping
@PreAuthorize("hasAuthority('EDIT_COMPANIES')")
fun save(@Valid @RequestBody company: CompanySaveDto) =
created<Company>(COMPANY_CONTROLLER_PATH) {
companyService.save(company)
}
fun save(@Valid @RequestBody company: CompanyDto) =
created<CompanyDto>(Constants.ControllerPaths.COMPANY) {
companyLogic.save(company)
}
@PutMapping
@PreAuthorize("hasAuthority('EDIT_COMPANIES')")
fun update(@Valid @RequestBody company: CompanyUpdateDto) =
noContent {
companyService.update(company)
}
fun update(@Valid @RequestBody company: CompanyDto) =
noContent {
companyLogic.update(company)
}
@DeleteMapping("{id}")
@PreAuthorize("hasAuthority('EDIT_COMPANIES')")
fun deleteById(@PathVariable id: Long) =
noContent {
companyService.deleteById(id)
}
noContent {
companyLogic.deleteById(id)
}
}

View File

@ -1,37 +1,35 @@
package dev.fyloz.colorrecipesexplorer.rest
import dev.fyloz.colorrecipesexplorer.logic.config.ConfigurationLogic
import dev.fyloz.colorrecipesexplorer.model.ConfigurationBase
import dev.fyloz.colorrecipesexplorer.model.ConfigurationDto
import dev.fyloz.colorrecipesexplorer.model.ConfigurationImageDto
import dev.fyloz.colorrecipesexplorer.model.account.Permission
import dev.fyloz.colorrecipesexplorer.model.account.toAuthority
import dev.fyloz.colorrecipesexplorer.restartApplication
import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService
import org.springframework.http.MediaType
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.Authentication
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import javax.validation.constraints.NotBlank
@RestController
@RequestMapping("api/config")
class ConfigurationController(val configurationService: ConfigurationService) {
class ConfigurationController(val configurationLogic: ConfigurationLogic) {
@GetMapping
fun getAll(@RequestParam(required = false) keys: String?, authentication: Authentication?) =
ok(with(configurationService) {
ok(with(configurationLogic) {
if (keys != null) getAll(keys) else getAll()
}.filter { authentication.hasAuthority(it) })
@GetMapping("{key}")
fun get(@PathVariable key: String, authentication: Authentication?) = with(configurationService.get(key)) {
fun get(@PathVariable key: String, authentication: Authentication?) = with(configurationLogic.get(key)) {
if (authentication.hasAuthority(this)) ok(this) else forbidden()
}
@PutMapping
@PreAuthorize("hasAuthority('ADMIN')")
fun set(@RequestBody configurations: List<ConfigurationDto>) = noContent {
configurationService.set(configurations)
configurationLogic.set(configurations)
}
@PostMapping("restart")
@ -44,24 +42,24 @@ class ConfigurationController(val configurationService: ConfigurationService) {
@GetMapping("icon")
fun getIcon() =
okFile(configurationService.getConfiguredIcon(), MediaType.IMAGE_PNG_VALUE)
okFile(configurationLogic.getConfiguredIcon(), MediaType.IMAGE_PNG_VALUE)
@PutMapping("icon")
@PreAuthorize("hasAuthority('ADMIN')")
fun setIcon(@RequestParam icon: MultipartFile) = noContent {
configurationService.setConfiguredIcon(icon)
configurationLogic.setConfiguredIcon(icon)
}
// Logo
@GetMapping("logo")
fun getLogo() =
okFile(configurationService.getConfiguredLogo(), MediaType.IMAGE_PNG_VALUE)
okFile(configurationLogic.getConfiguredLogo(), MediaType.IMAGE_PNG_VALUE)
@PutMapping("logo")
@PreAuthorize("hasAuthority('ADMIN')")
fun setLogo(@RequestParam logo: MultipartFile) = noContent {
configurationService.setConfiguredLogo(logo)
configurationLogic.setConfiguredLogo(logo)
}
}

View File

@ -1,8 +1,9 @@
package dev.fyloz.colorrecipesexplorer.rest
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.logic.config.ConfigurationLogic
import dev.fyloz.colorrecipesexplorer.logic.files.WriteableFileLogic
import dev.fyloz.colorrecipesexplorer.model.ConfigurationType
import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService
import dev.fyloz.colorrecipesexplorer.service.files.WriteableFileService
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
@ -10,19 +11,17 @@ import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import java.net.URI
const val FILE_CONTROLLER_PATH = "/api/file"
@RestController
@RequestMapping(FILE_CONTROLLER_PATH)
@RequestMapping(Constants.ControllerPaths.FILE)
class FileController(
private val fileService: WriteableFileService,
private val configService: ConfigurationService
private val fileLogic: WriteableFileLogic,
private val configurationLogic: ConfigurationLogic
) {
@GetMapping(produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE])
fun upload(
@RequestParam path: String,
@RequestParam(required = false) mediaType: String?
) = okFile(fileService.read(path), mediaType)
) = okFile(fileLogic.read(path), mediaType)
@PutMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
@PreAuthorize("hasAnyAuthority('WRITE_FILE')")
@ -31,7 +30,7 @@ class FileController(
@RequestParam path: String,
@RequestParam(required = false) overwrite: Boolean = false
): ResponseEntity<Void> {
fileService.write(file, path, overwrite)
fileLogic.write(file, path, overwrite)
return created(path)
}
@ -39,11 +38,11 @@ class FileController(
@PreAuthorize("hasAnyAuthority('WRITE_FILE')")
fun delete(@RequestParam path: String): ResponseEntity<Void> =
noContent {
fileService.delete(path)
fileLogic.delete(path)
}
private fun created(path: String): ResponseEntity<Void> =
ResponseEntity
.created(URI.create("${configService.get(ConfigurationType.INSTANCE_URL)}$FILE_CONTROLLER_PATH?path=$path"))
.created(URI.create("${configurationLogic.get(ConfigurationType.INSTANCE_URL)}${Constants.ControllerPaths.FILE}?path=$path"))
.build()
}

View File

@ -1,36 +1,34 @@
package dev.fyloz.colorrecipesexplorer.rest
import dev.fyloz.colorrecipesexplorer.model.MaterialQuantityDto
import dev.fyloz.colorrecipesexplorer.model.MixDeductDto
import dev.fyloz.colorrecipesexplorer.service.InventoryService
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.dtos.MaterialQuantityDto
import dev.fyloz.colorrecipesexplorer.dtos.MixDeductDto
import dev.fyloz.colorrecipesexplorer.logic.InventoryLogic
import org.springframework.context.annotation.Profile
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
private const val INVENTORY_CONTROLLER_PATH = "api/inventory"
@RestController
@RequestMapping(INVENTORY_CONTROLLER_PATH)
@RequestMapping(Constants.ControllerPaths.INVENTORY)
@Profile("!emergency")
class InventoryController(
private val inventoryService: InventoryService
private val inventoryLogic: InventoryLogic
) {
@PutMapping("add")
@PreAuthorize("hasAuthority('ADD_TO_INVENTORY')")
fun add(@RequestBody quantities: Collection<MaterialQuantityDto>) =
ok(inventoryService.add(quantities))
ok(inventoryLogic.add(quantities))
@PutMapping("deduct")
@PreAuthorize("hasAuthority('DEDUCT_FROM_INVENTORY')")
fun deduct(@RequestBody quantities: Collection<MaterialQuantityDto>) =
ok(inventoryService.deduct(quantities))
ok(inventoryLogic.deduct(quantities))
@PutMapping("deduct/mix")
@PreAuthorize("hasAuthority('DEDUCT_FROM_INVENTORY')")
fun deduct(@RequestBody mixRatio: MixDeductDto) =
ok(inventoryService.deductMix(mixRatio))
ok(inventoryLogic.deductMix(mixRatio))
}

View File

@ -1,83 +1,59 @@
package dev.fyloz.colorrecipesexplorer.rest
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewCatalog
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.service.MaterialService
import dev.fyloz.colorrecipesexplorer.dtos.MaterialDto
import dev.fyloz.colorrecipesexplorer.dtos.MaterialSaveDto
import dev.fyloz.colorrecipesexplorer.logic.MaterialLogic
import org.springframework.context.annotation.Profile
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import java.net.URI
import javax.validation.Valid
private const val MATERIAL_CONTROLLER_PATH = "api/material"
@RestController
@RequestMapping(MATERIAL_CONTROLLER_PATH)
@RequestMapping(Constants.ControllerPaths.MATERIAL)
@Profile("!emergency")
@PreAuthorizeViewCatalog
class MaterialController(
private val materialService: MaterialService
private val materialLogic: MaterialLogic
) {
@GetMapping
fun getAll() =
ok(materialService.getAllForOutput())
@GetMapping("notmixtype")
fun getAllNotMixType() =
ok(materialService.getAllNotMixType())
ok(materialLogic.getAll())
@GetMapping("{id}")
fun getById(@PathVariable id: Long) =
ok(materialService.getByIdForOutput(id))
ok(materialLogic.getById(id))
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
@PreAuthorize("hasAuthority('EDIT_MATERIALS')")
fun save(@Valid material: MaterialSaveDto, simdutFile: MultipartFile?) =
created<MaterialOutputDto>(MATERIAL_CONTROLLER_PATH) {
with(materialService) {
save(
materialSaveDto(
name = material.name,
inventoryQuantity = material.inventoryQuantity,
materialTypeId = material.materialTypeId,
simdutFile = simdutFile
)
).toOutput()
}
created<MaterialDto>(Constants.ControllerPaths.MATERIAL) {
materialLogic.save(material.copy(simdutFile = simdutFile))
}
@PutMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
@PreAuthorize("hasAuthority('EDIT_MATERIALS')")
fun update(@Valid material: MaterialUpdateDto, simdutFile: MultipartFile?) =
fun update(@Valid material: MaterialSaveDto, simdutFile: MultipartFile?) =
noContent {
materialService.update(
materialUpdateDto(
id = material.id,
name = material.name,
inventoryQuantity = material.inventoryQuantity,
materialTypeId = material.materialTypeId,
simdutFile = simdutFile
)
)
materialLogic.update(material.copy(simdutFile = simdutFile))
}
@DeleteMapping("{id}")
@PreAuthorize("hasAuthority('EDIT_MATERIALS')")
fun deleteById(@PathVariable id: Long) =
noContent {
materialService.deleteById(id)
materialLogic.deleteById(id)
}
@GetMapping("mix/create/{recipeId}")
fun getAllForMixCreation(@PathVariable recipeId: Long) =
ok(materialService.getAllForMixCreation(recipeId))
ok(materialLogic.getAllForRecipe(recipeId))
@GetMapping("mix/update/{mixId}")
fun getAllForMixUpdate(@PathVariable mixId: Long) =
ok(materialService.getAllForMixUpdate(mixId))
ok(materialLogic.getAllForMix(mixId))
}

View File

@ -1,49 +1,46 @@
package dev.fyloz.colorrecipesexplorer.rest
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewCatalog
import dev.fyloz.colorrecipesexplorer.model.MaterialType
import dev.fyloz.colorrecipesexplorer.model.MaterialTypeSaveDto
import dev.fyloz.colorrecipesexplorer.model.MaterialTypeUpdateDto
import dev.fyloz.colorrecipesexplorer.service.MaterialTypeService
import dev.fyloz.colorrecipesexplorer.dtos.MaterialTypeDto
import dev.fyloz.colorrecipesexplorer.logic.MaterialTypeLogic
import org.springframework.context.annotation.Profile
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.*
import javax.validation.Valid
private const val MATERIAL_TYPE_CONTROLLER_PATH = "api/materialtype"
@RestController
@RequestMapping(MATERIAL_TYPE_CONTROLLER_PATH)
@RequestMapping(Constants.ControllerPaths.MATERIAL_TYPE)
@Profile("!emergency")
@PreAuthorizeViewCatalog
class MaterialTypeController(private val materialTypeService: MaterialTypeService) {
class MaterialTypeController(private val materialTypeLogic: MaterialTypeLogic) {
@GetMapping
fun getAll() =
ok(materialTypeService.getAllForOutput())
ok(materialTypeLogic.getAll())
@GetMapping("{id}")
fun getById(@PathVariable id: Long) =
ok(materialTypeService.getByIdForOutput(id))
ok(materialTypeLogic.getById(id))
@PostMapping
@PreAuthorize("hasAuthority('EDIT_MATERIAL_TYPES')")
fun save(@Valid @RequestBody materialType: MaterialTypeSaveDto) =
created<MaterialType>(MATERIAL_TYPE_CONTROLLER_PATH) {
materialTypeService.save(materialType)
}
fun save(@Valid @RequestBody materialType: MaterialTypeDto) =
created<MaterialTypeDto>(Constants.ControllerPaths.MATERIAL_TYPE) {
materialTypeLogic.save(materialType)
}
@PutMapping
@PreAuthorize("hasAuthority('EDIT_MATERIAL_TYPES')")
fun update(@Valid @RequestBody materialType: MaterialTypeUpdateDto) =
noContent {
materialTypeService.update(materialType)
}
fun update(@Valid @RequestBody materialType: MaterialTypeDto) =
noContent {
materialTypeLogic.updateNonSystemType(materialType)
}
@DeleteMapping("{id}")
@PreAuthorize("hasAuthority('EDIT_MATERIAL_TYPES')")
fun deleteById(@PathVariable id: Long) =
noContent {
materialTypeService.deleteById(id)
}
noContent {
materialTypeLogic.deleteById(id)
}
}

View File

@ -0,0 +1,42 @@
package dev.fyloz.colorrecipesexplorer.rest
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeEditRecipes
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewRecipes
import dev.fyloz.colorrecipesexplorer.dtos.MixDto
import dev.fyloz.colorrecipesexplorer.dtos.MixSaveDto
import dev.fyloz.colorrecipesexplorer.logic.MixLogic
import org.springframework.context.annotation.Profile
import org.springframework.web.bind.annotation.*
import javax.validation.Valid
@RestController
@RequestMapping(Constants.ControllerPaths.MIX)
@Profile("!emergency")
@PreAuthorizeViewRecipes
class MixController(private val mixLogic: MixLogic) {
@GetMapping("{id}")
fun getById(@PathVariable id: Long) =
ok(mixLogic.getById(id))
@PostMapping
@PreAuthorizeEditRecipes
fun save(@Valid @RequestBody mix: MixSaveDto) =
created<MixDto>(Constants.ControllerPaths.MIX) {
mixLogic.save(mix)
}
@PutMapping
@PreAuthorizeEditRecipes
fun update(@Valid @RequestBody mix: MixSaveDto) =
noContent {
mixLogic.update(mix)
}
@DeleteMapping("{id}")
@PreAuthorizeEditRecipes
fun deleteById(@PathVariable id: Long) =
noContent {
mixLogic.deleteById(id)
}
}

View File

@ -1,116 +1,77 @@
package dev.fyloz.colorrecipesexplorer.rest
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeEditRecipes
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewRecipes
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.service.MixService
import dev.fyloz.colorrecipesexplorer.service.RecipeImageService
import dev.fyloz.colorrecipesexplorer.service.RecipeService
import dev.fyloz.colorrecipesexplorer.dtos.RecipeDto
import dev.fyloz.colorrecipesexplorer.dtos.RecipePublicDataDto
import dev.fyloz.colorrecipesexplorer.dtos.RecipeSaveDto
import dev.fyloz.colorrecipesexplorer.dtos.RecipeUpdateDto
import dev.fyloz.colorrecipesexplorer.logic.RecipeImageLogic
import dev.fyloz.colorrecipesexplorer.logic.RecipeLogic
import org.springframework.context.annotation.Profile
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import javax.validation.Valid
private const val RECIPE_CONTROLLER_PATH = "api/recipe"
private const val MIX_CONTROLLER_PATH = "api/recipe/mix"
@RestController
@RequestMapping(RECIPE_CONTROLLER_PATH)
@RequestMapping(Constants.ControllerPaths.RECIPE)
@Profile("!emergency")
@PreAuthorizeViewRecipes
class RecipeController(
private val recipeService: RecipeService,
private val recipeImageService: RecipeImageService
) {
class RecipeController(private val recipeLogic: RecipeLogic, private val recipeImageLogic: RecipeImageLogic) {
@GetMapping
fun getAll(@RequestParam(required = false) name: String?) =
if (name == null)
ok(recipeService.getAllForOutput())
else
ok(with(recipeService) {
getAllByName(name).map { it.toOutput() }
})
fun getAll(@RequestParam(required = false) name: String?) = ok(
if (name == null) {
recipeLogic.getAll()
} else {
recipeLogic.getAllByName(name)
}
)
@GetMapping("{id}")
fun getById(@PathVariable id: Long) =
ok(recipeService.getByIdForOutput(id))
fun getById(@PathVariable id: Long) = ok(recipeLogic.getById(id))
@PostMapping
@PreAuthorizeEditRecipes
fun save(@Valid @RequestBody recipe: RecipeSaveDto) =
created<RecipeOutputDto>(RECIPE_CONTROLLER_PATH) {
with(recipeService) {
save(recipe).toOutput()
}
created<RecipeDto>(Constants.ControllerPaths.RECIPE) {
recipeLogic.save(recipe)
}
@PutMapping
@PreAuthorizeEditRecipes
fun update(@Valid @RequestBody recipe: RecipeUpdateDto) =
noContent {
recipeService.update(recipe)
}
fun update(@Valid @RequestBody recipe: RecipeUpdateDto) = noContent {
recipeLogic.update(recipe)
}
@PutMapping("public")
@PreAuthorize("hasAuthority('EDIT_RECIPES_PUBLIC_DATA')")
fun updatePublicData(@Valid @RequestBody publicDataDto: RecipePublicDataDto) =
noContent {
recipeService.updatePublicData(publicDataDto)
}
fun updatePublicData(@Valid @RequestBody publicDataDto: RecipePublicDataDto) = noContent {
recipeLogic.updatePublicData(publicDataDto)
}
@DeleteMapping("{id}")
@PreAuthorizeEditRecipes
fun deleteById(@PathVariable id: Long) =
noContent {
recipeService.deleteById(id)
}
fun deleteById(@PathVariable id: Long) = noContent {
recipeLogic.deleteById(id)
}
@GetMapping("{recipeId}/image")
fun getAllImages(@PathVariable recipeId: Long) =
ok(recipeImageLogic.getAllImages(recipeId))
@PutMapping("{recipeId}/image", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
@PreAuthorizeEditRecipes
fun downloadImage(@PathVariable recipeId: Long, image: MultipartFile): ResponseEntity<RecipeOutputDto> {
recipeImageService.download(image, recipeService.getById(recipeId))
return getById(recipeId)
fun downloadImage(@PathVariable recipeId: Long, image: MultipartFile) =
fileCreated("images/recipes/$recipeId") {
recipeImageLogic.download(image, recipeId)
}
@DeleteMapping("{recipeId}/image/{id}")
@PreAuthorizeEditRecipes
fun deleteImage(@PathVariable recipeId: Long, @PathVariable id: String) = noContent {
recipeImageLogic.delete(recipeId, id)
}
@DeleteMapping("{recipeId}/image/{name}")
@PreAuthorizeEditRecipes
fun deleteImage(@PathVariable recipeId: Long, @PathVariable name: String) =
noContent {
recipeImageService.delete(recipeService.getById(recipeId), name)
}
}
@RestController
@RequestMapping(MIX_CONTROLLER_PATH)
@Profile("!emergency")
@PreAuthorizeViewRecipes
class MixController(private val mixService: MixService) {
@GetMapping("{id}")
fun getById(@PathVariable id: Long) =
ok(mixService.getByIdForOutput(id))
@PostMapping
@PreAuthorizeEditRecipes
fun save(@Valid @RequestBody mix: MixSaveDto) =
created<Mix>(MIX_CONTROLLER_PATH) {
mixService.save(mix)
}
@PutMapping
@PreAuthorizeEditRecipes
fun update(@Valid @RequestBody mix: MixUpdateDto) =
noContent {
mixService.update(mix)
}
@DeleteMapping("{id}")
@PreAuthorizeEditRecipes
fun deleteById(@PathVariable id: Long) =
noContent {
mixService.deleteById(id)
}
}

View File

@ -1,7 +1,9 @@
package dev.fyloz.colorrecipesexplorer.rest
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
import dev.fyloz.colorrecipesexplorer.model.Model
import dev.fyloz.colorrecipesexplorer.dtos.EntityDto
import dev.fyloz.colorrecipesexplorer.model.ModelEntity
import org.springframework.core.io.Resource
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
@ -34,12 +36,22 @@ fun okFile(file: Resource, mediaType: String? = null): ResponseEntity<Resource>
.contentType(MediaType.parseMediaType(mediaType ?: DEFAULT_MEDIA_TYPE))
.body(file)
/** Creates a HTTP CREATED [ResponseEntity] for the file created by the given [producer]. */
fun fileCreated(basePath: String, producer: () -> String): ResponseEntity<String> {
val fileName = producer()
val path = "${Constants.ControllerPaths.FILE}?path=$basePath/$fileName"
return ResponseEntity.created(URI.create(path)).body(fileName)
}
/** Creates a HTTP CREATED [ResponseEntity] from the given [body] with the location set to [controllerPath]/id. */
fun <T : Model> created(controllerPath: String, body: T): ResponseEntity<T> =
created(controllerPath, body, body.id!!)
@JvmName("createdDto")
fun <T : EntityDto> created(controllerPath: String, body: T): ResponseEntity<T> =
created(controllerPath, body, body.id)
/** Creates a HTTP CREATED [ResponseEntity] with the result of the given [producer] as its body. */
fun <T : Model> created(controllerPath: String, producer: () -> T): ResponseEntity<T> =
@JvmName("createdDto")
fun <T : EntityDto> created(controllerPath: String, producer: () -> T): ResponseEntity<T> =
created(controllerPath, producer())
/** Creates a HTTP CREATED [ResponseEntity] from the given [body] with the location set to [controllerPath]/id. */

View File

@ -1,9 +1,8 @@
package dev.fyloz.colorrecipesexplorer.rest
import dev.fyloz.colorrecipesexplorer.model.touchupkit.TouchUpKitOutputDto
import dev.fyloz.colorrecipesexplorer.model.touchupkit.TouchUpKitSaveDto
import dev.fyloz.colorrecipesexplorer.model.touchupkit.TouchUpKitUpdateDto
import dev.fyloz.colorrecipesexplorer.service.TouchUpKitService
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.dtos.TouchUpKitDto
import dev.fyloz.colorrecipesexplorer.logic.TouchUpKitLogic
import org.springframework.context.annotation.Profile
import org.springframework.core.io.Resource
import org.springframework.http.MediaType
@ -12,53 +11,47 @@ import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.*
import javax.validation.Valid
const val TOUCH_UP_KIT_CONTROLLER_PATH = "/api/touchupkit"
@RestController
@RequestMapping(TOUCH_UP_KIT_CONTROLLER_PATH)
@RequestMapping(Constants.ControllerPaths.TOUCH_UP_KIT)
@Profile("!emergency")
@PreAuthorize("hasAuthority('VIEW_TOUCH_UP_KITS')")
class TouchUpKitController(
private val touchUpKitService: TouchUpKitService
private val touchUpKitLogic: TouchUpKitLogic
) {
@GetMapping
fun getAll() =
ok(touchUpKitService.getAllForOutput())
fun getAll() = ok(touchUpKitLogic.getAll())
@GetMapping("{id}")
fun getById(@PathVariable id: Long) =
ok(touchUpKitService.getByIdForOutput(id))
fun getById(@PathVariable id: Long) = ok(touchUpKitLogic.getById(id))
@PostMapping
@PreAuthorize("hasAuthority('EDIT_TOUCH_UP_KITS')")
fun save(@Valid @RequestBody touchUpKit: TouchUpKitSaveDto) =
created<TouchUpKitOutputDto>(TOUCH_UP_KIT_CONTROLLER_PATH) {
with(touchUpKitService) {
save(touchUpKit).toOutput()
}
fun save(@Valid @RequestBody touchUpKit: TouchUpKitDto) =
created<TouchUpKitDto>(Constants.ControllerPaths.TOUCH_UP_KIT) {
touchUpKitLogic.save(touchUpKit)
}
@PutMapping
@PreAuthorize("hasAuthority('EDIT_TOUCH_UP_KITS')")
fun update(@Valid @RequestBody touchUpKit: TouchUpKitUpdateDto) = noContent {
touchUpKitService.update(touchUpKit)
fun update(@Valid @RequestBody touchUpKit: TouchUpKitDto) = noContent {
touchUpKitLogic.update(touchUpKit)
}
@PutMapping("{id}/complete")
@PreAuthorize("hasAuthority('EDIT_TOUCH_UP_KITS')")
fun complete(@PathVariable id: Long) = noContent {
touchUpKitService.complete(id)
touchUpKitLogic.complete(id)
}
@DeleteMapping("{id}")
@PreAuthorize("hasAuthority('EDIT_TOUCH_UP_KITS')")
fun deleteById(@PathVariable id: Long) = noContent {
touchUpKitService.deleteById(id)
touchUpKitLogic.deleteById(id)
}
@GetMapping("pdf")
fun getJobPdf(@RequestParam project: String): ResponseEntity<Resource> {
with(touchUpKitService.generateJobPdfResource(project)) {
with(touchUpKitLogic.generateJobPdfResource(project)) {
return ResponseEntity.ok()
.header("Content-Disposition", "filename=TouchUpKit_$project.pdf")
.contentLength(this.contentLength())

View File

@ -1,50 +1,27 @@
package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent
import dev.fyloz.colorrecipesexplorer.dtos.CompanyDto
import dev.fyloz.colorrecipesexplorer.model.Company
import dev.fyloz.colorrecipesexplorer.repository.CompanyRepository
import org.springframework.context.annotation.Lazy
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Service
interface CompanyService :
ExternalNamedModelService<Company, CompanySaveDto, CompanyUpdateDto, Company, CompanyRepository> {
/** Checks if the given [company] is used by one or more recipes. */
fun isLinkedToRecipes(company: Company): Boolean
interface CompanyService : Service<CompanyDto, Company, CompanyRepository> {
/** Checks if a company with the given [name] exists. */
fun existsByName(name: String, id: Long?): Boolean
/** Checks if a recipe depends on the company with the given [id]. */
fun isUsedByRecipe(id: Long): Boolean
}
@Service
@Profile("!emergency")
class CompanyServiceImpl(
companyRepository: CompanyRepository,
@Lazy val recipeService: RecipeService
) :
AbstractExternalNamedModelService<Company, CompanySaveDto, CompanyUpdateDto, Company, CompanyRepository>(
companyRepository
),
CompanyService {
override fun idNotFoundException(id: Long) = companyIdNotFoundException(id)
override fun idAlreadyExistsException(id: Long) = companyIdAlreadyExistsException(id)
override fun nameNotFoundException(name: String) = companyNameNotFoundException(name)
override fun nameAlreadyExistsException(name: String) = companyNameAlreadyExistsException(name)
@ServiceComponent
class DefaultCompanyService(repository: CompanyRepository) :
BaseService<CompanyDto, Company, CompanyRepository>(repository), CompanyService {
override fun existsByName(name: String, id: Long?) = repository.existsByNameAndIdNot(name, id ?: 0)
override fun isUsedByRecipe(id: Long) = repository.isUsedByRecipe(id)
override fun Company.toOutput() = this
override fun toDto(entity: Company) =
CompanyDto(entity.id, entity.name)
override fun isLinkedToRecipes(company: Company): Boolean = recipeService.existsByCompany(company)
override fun update(entity: CompanyUpdateDto): Company {
// Lazy loaded to prevent checking the database when not necessary
val persistedCompany by lazy { getById(entity.id) }
return update(with(entity) {
company(
id = id,
name = if (name != null && name.isNotBlank()) name else persistedCompany.name
)
})
}
override fun delete(entity: Company) {
if (!repository.canBeDeleted(entity.id!!)) throw cannotDeleteCompany(entity)
super.delete(entity)
}
}
override fun toEntity(dto: CompanyDto) =
Company(dto.id, dto.name)
}

View File

@ -0,0 +1,31 @@
package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent
import dev.fyloz.colorrecipesexplorer.dtos.GroupDto
import dev.fyloz.colorrecipesexplorer.model.account.Group
import dev.fyloz.colorrecipesexplorer.model.account.Permission
import dev.fyloz.colorrecipesexplorer.model.account.flat
import dev.fyloz.colorrecipesexplorer.repository.GroupRepository
interface GroupService : Service<GroupDto, Group, GroupRepository> {
/** Checks if a group with the given [name] and a different [id] exists. */
fun existsByName(name: String, id: Long? = null): Boolean
/** Flatten the given the permissions of the given [group]. */
fun flattenPermissions(group: Group): List<Permission>
}
@ServiceComponent
class DefaultGroupService(repository: GroupRepository) : BaseService<GroupDto, Group, GroupRepository>(repository),
GroupService {
override fun existsByName(name: String, id: Long?) = repository.existsByNameAndIdNot(name, id ?: 0L)
override fun toDto(entity: Group) =
GroupDto(entity.id, entity.name, flattenPermissions(entity), entity.permissions)
override fun toEntity(dto: GroupDto) =
Group(dto.id, dto.name, dto.permissions)
override fun flattenPermissions(group: Group) =
group.permissions.flatMap { it.flat() }.filter { !it.deprecated }
}

View File

@ -1,115 +0,0 @@
package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.exception.RestException
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.utils.mapMayThrow
import org.springframework.context.annotation.Profile
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import javax.transaction.Transactional
interface InventoryService {
/** Adds each given [MaterialQuantityDto] to the inventory and returns the updated quantities. */
fun add(materialQuantities: Collection<MaterialQuantityDto>): Collection<MaterialQuantityDto>
/** Adds a given quantity to the given [Material]'s inventory quantity according to the given [materialQuantity] and returns the updated quantity. */
fun add(materialQuantity: MaterialQuantityDto): Float
/** Deducts the inventory quantity of each [Material]s in the mix according to the ratio defined in the given [mixRatio] and returns the updated quantities. */
fun deductMix(mixRatio: MixDeductDto): Collection<MaterialQuantityDto>
/** Deducts the inventory quantity of each given [MaterialQuantityDto] and returns the updated quantities. */
fun deduct(materialQuantities: Collection<MaterialQuantityDto>): Collection<MaterialQuantityDto>
/** Deducts the inventory quantity of a given [Material] by a given quantity according to the given [materialQuantity] and returns the updated quantity. */
fun deduct(materialQuantity: MaterialQuantityDto): Float
}
@Service
@Profile("!emergency")
class InventoryServiceImpl(
private val materialService: MaterialService,
private val mixService: MixService
) : InventoryService {
@Transactional
override fun add(materialQuantities: Collection<MaterialQuantityDto>) =
materialQuantities.map {
materialQuantityDto(materialId = it.material, quantity = add(it))
}
override fun add(materialQuantity: MaterialQuantityDto) =
materialService.updateQuantity(
materialService.getById(materialQuantity.material),
materialQuantity.quantity
)
@Transactional
override fun deductMix(mixRatio: MixDeductDto): Collection<MaterialQuantityDto> {
val mix = mixService.getById(mixRatio.id)
val firstMixMaterial = mix.mixMaterials.first()
val adjustedFirstMaterialQuantity = firstMixMaterial.quantity * mixRatio.ratio
fun adjustQuantity(mixMaterial: MixMaterial): Float =
if (!mixMaterial.material.materialType!!.usePercentages)
mixMaterial.quantity * mixRatio.ratio
else
(mixMaterial.quantity * adjustedFirstMaterialQuantity) / 100f
return deduct(mix.mixMaterials.map {
materialQuantityDto(
materialId = it.material.id!!,
quantity = adjustQuantity(it)
)
})
}
@Transactional
override fun deduct(materialQuantities: Collection<MaterialQuantityDto>): Collection<MaterialQuantityDto> {
val thrown = mutableListOf<NotEnoughInventoryException>()
val updatedQuantities =
materialQuantities.mapMayThrow<MaterialQuantityDto, MaterialQuantityDto, NotEnoughInventoryException>(
{ thrown.add(it) }
) {
materialQuantityDto(materialId = it.material, quantity = deduct(it))
}
if (thrown.isNotEmpty()) {
throw MultiplesNotEnoughInventoryException(thrown)
}
return updatedQuantities
}
override fun deduct(materialQuantity: MaterialQuantityDto): Float =
with(materialService.getById(materialQuantity.material)) {
if (this.inventoryQuantity >= materialQuantity.quantity) {
materialService.updateQuantity(this, -materialQuantity.quantity)
} else {
throw NotEnoughInventoryException(materialQuantity.quantity, this)
}
}
}
class NotEnoughInventoryException(quantity: Float, material: Material) :
RestException(
"notenoughinventory",
"Not enough inventory",
HttpStatus.BAD_REQUEST,
"Cannot deduct ${quantity}mL of ${material.name} because there is only ${material.inventoryQuantity}mL in inventory",
mapOf(
"material" to material.name,
"materialId" to material.id.toString(),
"requestQuantity" to quantity,
"availableQuantity" to material.inventoryQuantity
)
)
class MultiplesNotEnoughInventoryException(exceptions: List<NotEnoughInventoryException>) :
RestException(
"notenoughinventory-multiple",
"Not enough inventory",
HttpStatus.BAD_REQUEST,
"Cannot deduct requested quantities because there is no enough of them in inventory",
mapOf(
"lowQuantities" to exceptions.map { it.extensions }
)
)

View File

@ -1,145 +1,51 @@
package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent
import dev.fyloz.colorrecipesexplorer.dtos.MaterialDto
import dev.fyloz.colorrecipesexplorer.logic.files.FileLogic
import dev.fyloz.colorrecipesexplorer.model.Material
import dev.fyloz.colorrecipesexplorer.repository.MaterialRepository
import dev.fyloz.colorrecipesexplorer.rest.FILE_CONTROLLER_PATH
import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService
import dev.fyloz.colorrecipesexplorer.service.files.WriteableFileService
import io.jsonwebtoken.lang.Assert
import org.springframework.context.annotation.Lazy
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Service
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import org.springframework.beans.factory.annotation.Qualifier
interface MaterialService :
ExternalNamedModelService<Material, MaterialSaveDto, MaterialUpdateDto, MaterialOutputDto, MaterialRepository> {
/** Checks if a material with the given [materialType] exists. */
fun existsByMaterialType(materialType: MaterialType): Boolean
interface MaterialService : Service<MaterialDto, Material, MaterialRepository> {
/** Checks if a material with the given [name] and a different [id] exists. */
fun existsByName(name: String, id: Long?): Boolean
/** Checks if the given [material] has a SIMDUT file. */
fun hasSimdut(material: Material): Boolean
/** Updates the [inventoryQuantity] of the [Material] with the given [id]. */
fun updateInventoryQuantityById(id: Long, inventoryQuantity: Float)
/** Gets all materials that are not a mix type. */
fun getAllNotMixType(): Collection<MaterialOutputDto>
/** Gets all materials available for the creation of a mix for the recipe with the given [recipeId], including normal materials and materials from [MixType]s included in the said recipe. */
fun getAllForMixCreation(recipeId: Long): Collection<MaterialOutputDto>
/** Gets all materials available for updating the mix with the given [mixId], including normal materials and materials from [MixType]s included in the mix recipe, excluding the material of the [MixType] of the said mix. */
fun getAllForMixUpdate(mixId: Long): Collection<MaterialOutputDto>
/** Updates the quantity of the given [material] with the given [factor] and returns the updated quantity. */
fun updateQuantity(material: Material, factor: Float): Float
/** Checks if a mix material or a mix type depends on the material with the given [id]. */
fun isUsedByMixMaterialOrMixType(id: Long): Boolean
}
@Service
@Profile("!emergency")
class MaterialServiceImpl(
materialRepository: MaterialRepository,
val recipeService: RecipeService,
val mixService: MixService,
@Lazy val materialTypeService: MaterialTypeService,
val fileService: WriteableFileService,
val configService: ConfigurationService
@ServiceComponent
class DefaultMaterialService(
repository: MaterialRepository,
private val materialTypeService: MaterialTypeService,
@Qualifier("defaultFileLogic") val fileLogic: FileLogic
) :
AbstractExternalNamedModelService<Material, MaterialSaveDto, MaterialUpdateDto, MaterialOutputDto, MaterialRepository>(
materialRepository
),
MaterialService {
override fun idNotFoundException(id: Long) = materialIdNotFoundException(id)
override fun idAlreadyExistsException(id: Long) = materialIdAlreadyExistsException(id)
override fun nameNotFoundException(name: String) = materialNameNotFoundException(name)
override fun nameAlreadyExistsException(name: String) = materialNameAlreadyExistsException(name)
BaseService<MaterialDto, Material, MaterialRepository>(repository), MaterialService {
override fun existsByName(name: String, id: Long?) = repository.existsByNameAndIdNot(name, id ?: 0)
override fun getAll() = repository.findAllByIsMixTypeIsFalse().map(::toDto)
override fun updateInventoryQuantityById(id: Long, inventoryQuantity: Float) =
repository.updateInventoryQuantityById(id, inventoryQuantity)
override fun Material.toOutput(): MaterialOutputDto =
MaterialOutputDto(
id = this.id!!,
name = this.name,
inventoryQuantity = this.inventoryQuantity,
isMixType = this.isMixType,
materialType = this.materialType!!,
simdutUrl = if (fileService.exists(this.simdutFilePath))
"${configService.getContent(ConfigurationType.INSTANCE_URL)}$FILE_CONTROLLER_PATH?path=${
URLEncoder.encode(
this.simdutFilePath,
StandardCharsets.UTF_8
)
}"
else null
override fun isUsedByMixMaterialOrMixType(id: Long) = repository.isUsedByMixMaterialOrMixType(id)
override fun toDto(entity: Material) =
MaterialDto(
entity.id,
entity.name,
entity.inventoryQuantity,
entity.isMixType,
materialTypeService.toDto(entity.materialType!!),
hasSimdut(entity)
)
override fun existsByMaterialType(materialType: MaterialType): Boolean =
repository.existsByMaterialType(materialType)
override fun toEntity(dto: MaterialDto) =
Material(dto.id, dto.name, dto.inventoryQuantity, dto.isMixType, materialTypeService.toEntity(dto.materialType))
override fun hasSimdut(material: Material): Boolean = fileService.exists(material.simdutFilePath)
override fun getAllNotMixType(): Collection<MaterialOutputDto> = getAllForOutput().filter { !it.isMixType }
override fun save(entity: MaterialSaveDto): Material =
save(with(entity) {
material(
name = entity.name,
inventoryQuantity = entity.inventoryQuantity,
materialType = materialTypeService.getById(materialTypeId),
isMixType = false
)
}).apply {
if (entity.simdutFile != null && !entity.simdutFile.isEmpty) fileService.write(
entity.simdutFile,
this.simdutFilePath,
false
)
}
override fun update(entity: MaterialUpdateDto): Material {
val persistedMaterial by lazy {
getById(entity.id).apply { assertPersistedMaterial(this) }
}
return update(with(entity) {
material(
id = id,
name = if (name != null && name.isNotBlank()) name else persistedMaterial.name,
inventoryQuantity = if (inventoryQuantity != null && inventoryQuantity != Float.MIN_VALUE) inventoryQuantity else persistedMaterial.inventoryQuantity,
isMixType = persistedMaterial.isMixType,
materialType = if (materialTypeId != null) materialTypeService.getById(materialTypeId) else persistedMaterial.materialType
)
}).apply {
if (entity.simdutFile != null && !entity.simdutFile.isEmpty) fileService.write(
entity.simdutFile,
this.simdutFilePath,
true
)
}
}
override fun updateQuantity(material: Material, factor: Float) = with(material) {
val updatedQuantity = this.inventoryQuantity + factor
repository.updateInventoryQuantityById(this.id!!, updatedQuantity)
updatedQuantity
}
override fun getAllForMixCreation(recipeId: Long): Collection<MaterialOutputDto> {
val recipesMixTypes = recipeService.getById(recipeId).mixTypes
return getAllForOutput()
.filter { !it.isMixType || recipesMixTypes.any { mixType -> mixType.material.id == it.id } }
}
override fun getAllForMixUpdate(mixId: Long): Collection<MaterialOutputDto> {
val mix = mixService.getById(mixId)
val recipesMixTypes = mix.recipe.mixTypes
return getAllForOutput()
.filter { !it.isMixType || recipesMixTypes.any { mixType -> mixType.material.id == it.id } }
.filter { it.id != mix.mixType.material.id }
}
private fun assertPersistedMaterial(material: Material) {
Assert.notNull(material.name, "The persisted material with the id ${material.id} has a null name")
}
override fun delete(entity: Material) {
if (!repository.canBeDeleted(entity.id!!)) throw cannotDeleteMaterialException(entity)
if (fileService.exists(entity.simdutFilePath)) fileService.delete(entity.simdutFilePath)
super.delete(entity)
}
}
private fun hasSimdut(material: Material) =
fileLogic.exists("${Constants.FilePaths.SIMDUT}/${material.name}.pdf")
}

View File

@ -1,93 +1,44 @@
package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.model.validation.isNotNullAndNotBlank
import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent
import dev.fyloz.colorrecipesexplorer.dtos.MaterialTypeDto
import dev.fyloz.colorrecipesexplorer.model.MaterialType
import dev.fyloz.colorrecipesexplorer.repository.MaterialTypeRepository
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Service
interface MaterialTypeService :
ExternalNamedModelService<MaterialType, MaterialTypeSaveDto, MaterialTypeUpdateDto, MaterialType, MaterialTypeRepository> {
/** Checks if a material type with the given [prefix] exists. */
fun existsByPrefix(prefix: String): Boolean
interface MaterialTypeService : Service<MaterialTypeDto, MaterialType, MaterialTypeRepository> {
/** Checks if a system material type with the given [id] exists. */
fun existsById(id: Long, systemType: Boolean): Boolean
/** Checks if the given [materialType] is used by one or more materials. */
fun isUsedByMaterial(materialType: MaterialType): Boolean
/** Checks if a material type with the given [name] and a different [id] exists. */
fun existsByName(name: String, id: Long?): Boolean
/** Gets all system material types. */
fun getAllSystemTypes(): Collection<MaterialType>
/** Checks if a material type with the given [prefix] and a different [id] exists. */
fun existsByPrefix(prefix: String, id: Long?): Boolean
/** Gets all material types who are not a system type. */
fun getAllNonSystemType(): Collection<MaterialType>
/** Gets all material types which are or not a [systemType]. */
fun getAll(systemType: Boolean): Collection<MaterialTypeDto>
/** Allows to update the given system [materialType], should not be exposed to users. */
fun updateSystemType(materialType: MaterialType): MaterialType
/** Gets the material type with the given [name]. */
fun getByName(name: String): MaterialTypeDto?
/** Checks if a material depends on the material type with the given [id]. */
fun isUsedByMaterial(id: Long): Boolean
}
@Service
@Profile("!emergency")
class MaterialTypeServiceImpl(repository: MaterialTypeRepository, private val materialService: MaterialService) :
AbstractExternalNamedModelService<MaterialType, MaterialTypeSaveDto, MaterialTypeUpdateDto, MaterialType, MaterialTypeRepository>(
repository
), MaterialTypeService {
override fun idNotFoundException(id: Long) = materialTypeIdNotFoundException(id)
override fun idAlreadyExistsException(id: Long) = materialIdAlreadyExistsException(id)
override fun nameNotFoundException(name: String) = materialTypeNameNotFoundException(name)
override fun nameAlreadyExistsException(name: String) = materialTypeNameAlreadyExistsException(name)
@ServiceComponent
class DefaultMaterialTypeService(repository: MaterialTypeRepository) :
BaseService<MaterialTypeDto, MaterialType, MaterialTypeRepository>(repository), MaterialTypeService {
override fun existsById(id: Long, systemType: Boolean) = repository.existsByIdAndSystemTypeIs(id, systemType)
override fun existsByName(name: String, id: Long?) = repository.existsByNameAndIdNot(name, id ?: 0)
override fun existsByPrefix(prefix: String, id: Long?) = repository.existsByPrefixAndIdNot(prefix, id ?: 0)
override fun getAll(systemType: Boolean) = repository.findAllBySystemTypeIs(systemType).map(::toDto)
override fun getByName(name: String) = repository.findByName(name)?.let(::toDto)
override fun MaterialType.toOutput() = this
override fun isUsedByMaterial(id: Long) = repository.isUsedByMaterial(id)
override fun existsByPrefix(prefix: String): Boolean = repository.existsByPrefix(prefix)
override fun isUsedByMaterial(materialType: MaterialType): Boolean =
materialService.existsByMaterialType(materialType)
override fun toDto(entity: MaterialType) =
MaterialTypeDto(entity.id, entity.name, entity.prefix, entity.usePercentages, entity.systemType)
override fun getAllSystemTypes(): Collection<MaterialType> = repository.findAllBySystemTypeIs(true)
override fun getAllNonSystemType(): Collection<MaterialType> = repository.findAllBySystemTypeIs(false)
override fun save(entity: MaterialType): MaterialType {
if (existsByPrefix(entity.prefix))
throw materialTypePrefixAlreadyExistsException(entity.prefix)
return super<AbstractExternalNamedModelService>.save(entity)
}
override fun update(entity: MaterialTypeUpdateDto): MaterialType {
val persistedMaterialType by lazy { getById(entity.id) }
return update(with(entity) {
MaterialType(
id = id,
name = if (isNotNullAndNotBlank(name)) name else persistedMaterialType.name,
prefix = if (isNotNullAndNotBlank(prefix)) prefix else persistedMaterialType.prefix,
systemType = false
)
})
}
override fun updateSystemType(materialType: MaterialType) =
update(materialType, true)
override fun update(entity: MaterialType) =
update(entity, false)
private fun update(entity: MaterialType, allowSystemTypes: Boolean): MaterialType {
if (!allowSystemTypes && repository.existsByIdAndSystemTypeIsTrue(entity.id!!)) {
throw cannotUpdateSystemMaterialTypeException(entity)
}
with(repository.findByPrefix(entity.prefix)) {
if (this != null && id != entity.id)
throw materialTypePrefixAlreadyExistsException(entity.prefix)
}
return super.update(entity)
}
override fun delete(entity: MaterialType) {
if (repository.existsByIdAndSystemTypeIsTrue(entity.id!!)) {
throw cannotDeleteSystemMaterialTypeException(entity)
}
if (!repository.canBeDeleted(entity.id)) throw cannotDeleteMaterialTypeException(entity)
super.delete(entity)
}
}
override fun toEntity(dto: MaterialTypeDto) =
MaterialType(dto.id, dto.name, dto.prefix, dto.usePercentages, dto.systemType)
}

View File

@ -1,153 +1,23 @@
package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.exception.RestException
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent
import dev.fyloz.colorrecipesexplorer.dtos.MixMaterialDto
import dev.fyloz.colorrecipesexplorer.model.MixMaterial
import dev.fyloz.colorrecipesexplorer.repository.MixMaterialRepository
import dev.fyloz.colorrecipesexplorer.utils.findDuplicated
import dev.fyloz.colorrecipesexplorer.utils.hasGaps
import org.springframework.context.annotation.Lazy
import org.springframework.context.annotation.Profile
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
interface MixMaterialService : ModelService<MixMaterial, MixMaterialRepository> {
/** Checks if one or more mix materials have the given [material]. */
fun existsByMaterial(material: Material): Boolean
/** Creates [MixMaterial]s from the givens [MixMaterialDto]. */
fun create(mixMaterials: Set<MixMaterialDto>): Set<MixMaterial>
/** Creates a [MixMaterial] from a given [MixMaterialDto]. */
fun create(mixMaterial: MixMaterialDto): MixMaterial
/** Updates the [quantity] of the given [mixMaterial]. */
fun updateQuantity(mixMaterial: MixMaterial, quantity: Float): MixMaterial
/**
* Validates if the given [mixMaterials]. To be valid, the position of each mix material must be greater or equals to 1 and unique in the set.
* There must also be no gap between the positions. Also, the quantity of the first mix material in the set must not be expressed in percentages.
* If any of those criteria are not met, an [InvalidGroupStepsPositionsException] will be thrown.
*/
fun validateMixMaterials(mixMaterials: Set<MixMaterial>)
fun MixMaterial.toOutput(): MixMaterialOutputDto
interface MixMaterialService : Service<MixMaterialDto, MixMaterial, MixMaterialRepository> {
/** Checks if a mix material with the given [materialId] exists. */
fun existsByMaterialId(materialId: Long): Boolean
}
@Service
@Profile("!emergency")
class MixMaterialServiceImpl(
mixMaterialRepository: MixMaterialRepository,
@Lazy val materialService: MaterialService
) : AbstractModelService<MixMaterial, MixMaterialRepository>(mixMaterialRepository), MixMaterialService {
override fun idNotFoundException(id: Long) = mixMaterialIdNotFoundException(id)
override fun idAlreadyExistsException(id: Long) = mixMaterialIdAlreadyExistsException(id)
@ServiceComponent
class DefaultMixMaterialService(repository: MixMaterialRepository, private val materialService: MaterialService) :
BaseService<MixMaterialDto, MixMaterial, MixMaterialRepository>(repository), MixMaterialService {
override fun existsByMaterialId(materialId: Long) = repository.existsByMaterialId(materialId)
override fun MixMaterial.toOutput() = MixMaterialOutputDto(
this.id!!,
with(materialService) { this@toOutput.material.toOutput() },
this.quantity,
this.position
)
override fun existsByMaterial(material: Material): Boolean = repository.existsByMaterial(material)
override fun create(mixMaterials: Set<MixMaterialDto>): Set<MixMaterial> =
mixMaterials.map(::create).toSet()
override fun create(mixMaterial: MixMaterialDto): MixMaterial =
mixMaterial(
material = materialService.getById(mixMaterial.materialId),
quantity = mixMaterial.quantity,
position = mixMaterial.position
)
override fun updateQuantity(mixMaterial: MixMaterial, quantity: Float) =
update(mixMaterial.apply {
this.quantity = quantity
})
override fun validateMixMaterials(mixMaterials: Set<MixMaterial>) {
if (mixMaterials.isEmpty()) return
val sortedMixMaterials = mixMaterials.sortedBy { it.position }
val firstMixMaterial = sortedMixMaterials[0]
val errors = mutableSetOf<InvalidMixMaterialsPositionsError>()
// Check if the first mix material position is 1
fun isFirstMixMaterialPositionInvalid() =
sortedMixMaterials[0].position != 1
// Check if the first mix material is expressed in percents
fun isFirstMixMaterialPercentages() =
sortedMixMaterials[0].material.materialType!!.usePercentages
// Check if any positions is duplicated
fun getDuplicatedPositionsErrors() =
sortedMixMaterials
.findDuplicated { it.position }
.map { duplicatedMixMaterialsPositions(it) }
// Find all errors and throw if there is any
if (isFirstMixMaterialPositionInvalid()) errors += invalidFirstMixMaterialPosition(sortedMixMaterials[0])
errors += getDuplicatedPositionsErrors()
if (errors.isEmpty() && mixMaterials.hasGaps { it.position }) errors += gapBetweenStepsPositions()
if (errors.isNotEmpty()) {
throw InvalidMixMaterialsPositionsException(errors)
}
if (isFirstMixMaterialPercentages()) {
throw InvalidFirstMixMaterial(firstMixMaterial)
}
}
}
class InvalidMixMaterialsPositionsError(
val type: String,
val details: String
)
class InvalidMixMaterialsPositionsException(
val errors: Set<InvalidMixMaterialsPositionsError>
) : RestException(
"invalid-mixmaterial-position",
"Invalid mix materials positions",
HttpStatus.BAD_REQUEST,
"The position of mix materials are invalid",
mapOf(
"invalidMixMaterials" to errors
)
)
class InvalidFirstMixMaterial(
val mixMaterial: MixMaterial
) : RestException(
"invalid-mixmaterial-first",
"Invalid first mix material",
HttpStatus.BAD_REQUEST,
"The first mix material is invalid because its material must not be expressed in percents",
mapOf(
"mixMaterial" to mixMaterial
)
)
const val INVALID_FIRST_MIX_MATERIAL_POSITION_ERROR_CODE = "first"
const val DUPLICATED_MIX_MATERIALS_POSITIONS_ERROR_CODE = "duplicated"
const val GAP_BETWEEN_MIX_MATERIALS_POSITIONS_ERROR_CODE = "gap"
private fun invalidFirstMixMaterialPosition(mixMaterial: MixMaterial) =
InvalidMixMaterialsPositionsError(
INVALID_FIRST_MIX_MATERIAL_POSITION_ERROR_CODE,
"The position ${mixMaterial.position} is under the minimum of 1"
)
private fun duplicatedMixMaterialsPositions(position: Int) =
InvalidMixMaterialsPositionsError(
DUPLICATED_MIX_MATERIALS_POSITIONS_ERROR_CODE,
"The position $position is duplicated"
)
private fun gapBetweenStepsPositions() =
InvalidMixMaterialsPositionsError(
GAP_BETWEEN_MIX_MATERIALS_POSITIONS_ERROR_CODE,
"There is a gap between mix materials positions"
)
override fun toDto(entity: MixMaterial) =
MixMaterialDto(entity.id, materialService.toDto(entity.material), entity.quantity, entity.position)
override fun toEntity(dto: MixMaterialDto) =
MixMaterial(dto.id, materialService.toEntity(dto.material), dto.quantity, dto.position)
}

View File

@ -0,0 +1,34 @@
package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent
import dev.fyloz.colorrecipesexplorer.dtos.MixMixTypeDto
import dev.fyloz.colorrecipesexplorer.model.MixMixType
import dev.fyloz.colorrecipesexplorer.repository.MixMixTypeRepository
interface MixMixTypeService : Service<MixMixTypeDto, MixMixType, MixMixTypeRepository> {
fun getAllByMixId(mixId: Long): List<MixMixTypeDto>
fun saveAllForMixId(mixMixTypes: List<MixMixTypeDto>, mixId: Long): List<MixMixTypeDto>
}
@ServiceComponent
class DefaultMixMixTypeService(repository: MixMixTypeRepository, private val mixTypeService: MixTypeService) :
BaseService<MixMixTypeDto, MixMixType, MixMixTypeRepository>(repository), MixMixTypeService {
override fun getAllByMixId(mixId: Long) = repository.findAllByMixId(mixId).map(::toDto)
override fun saveAllForMixId(mixMixTypes: List<MixMixTypeDto>, mixId: Long): List<MixMixTypeDto> {
repository.deleteAllByMixId(mixId)
mixMixTypes.forEach { saveForMixId(it, mixId) }
return getAllByMixId(mixId)
}
fun saveForMixId(mixMixType: MixMixTypeDto, mixId: Long) =
repository.saveForMixId(mixMixType.id, mixMixType.mixType.id, mixId, mixMixType.quantity, mixMixType.position)
override fun toDto(entity: MixMixType) =
MixMixTypeDto(entity.id, mixTypeService.toDto(entity.mixType), entity.quantity, entity.position)
override fun toEntity(dto: MixMixTypeDto) =
MixMixType(dto.id, mixTypeService.toEntity(dto.mixType), dto.quantity, dto.position)
}

View File

@ -1,105 +1,54 @@
package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent
import dev.fyloz.colorrecipesexplorer.dtos.MixDto
import dev.fyloz.colorrecipesexplorer.dtos.MixQuantitiesDto
import dev.fyloz.colorrecipesexplorer.model.Mix
import dev.fyloz.colorrecipesexplorer.repository.MixRepository
import dev.fyloz.colorrecipesexplorer.utils.setAll
import org.springframework.context.annotation.Lazy
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Service
import javax.transaction.Transactional
interface MixService : ExternalModelService<Mix, MixSaveDto, MixUpdateDto, MixOutputDto, MixRepository> {
/** Gets all mixes with the given [mixType]. */
fun getAllByMixType(mixType: MixType): Collection<Mix>
interface MixService : Service<MixDto, Mix, MixRepository> {
/** Gets all mixes with the mix type with the given [mixTypeId]. */
fun getAllByMixTypeId(mixTypeId: Long): Collection<MixDto>
/** Checks if a [MixType] is shared by several [Mix]es or not. */
fun mixTypeIsShared(mixType: MixType): Boolean
/** Updates the location of each [Mix] in the given [MixLocationDto]s. */
fun updateLocations(updatedLocations: Collection<MixLocationDto>)
/** Updates the location of a given [Mix] to the given [MixLocationDto]. */
fun updateLocation(updatedLocation: MixLocationDto)
/** Updates the [location] of the mix with the given [id]. */
fun updateLocationById(id: Long, location: String?)
}
@Service
@Profile("!emergency")
class MixServiceImpl(
mixRepository: MixRepository,
@Lazy val recipeService: RecipeService,
@Lazy val materialTypeService: MaterialTypeService,
val mixMaterialService: MixMaterialService,
val mixTypeService: MixTypeService
) : AbstractExternalModelService<Mix, MixSaveDto, MixUpdateDto, MixOutputDto, MixRepository>(mixRepository),
MixService {
override fun idNotFoundException(id: Long) = mixIdNotFoundException(id)
override fun idAlreadyExistsException(id: Long) = mixIdAlreadyExistsException(id)
@ServiceComponent
class DefaultMixService(
repository: MixRepository,
private val mixTypeService: MixTypeService,
private val mixMaterialService: MixMaterialService,
private val mixMixTypeService: MixMixTypeService
) : BaseService<MixDto, Mix, MixRepository>(repository), MixService {
override fun getAllByMixTypeId(mixTypeId: Long) = repository.findAllByMixTypeId(mixTypeId).map(::toDto)
override fun updateLocationById(id: Long, location: String?) = repository.updateLocationById(id, location)
override fun getAllByMixType(mixType: MixType): Collection<Mix> = repository.findAllByMixType(mixType)
override fun mixTypeIsShared(mixType: MixType): Boolean = getAllByMixType(mixType).count() > 1
override fun save(dto: MixDto): MixDto {
val savedMix = super.save(dto)
val savedMixMixTypes = mixMixTypeService.saveAllForMixId(dto.mixQuantities.mixTypes, savedMix.id)
override fun Mix.toOutput() = MixOutputDto(
this.id!!,
this.location,
this.mixType,
this.mixMaterials.map {
with(mixMaterialService) {
return@with it.toOutput()
}
}.toSet()
)
@Transactional
override fun save(entity: MixSaveDto): Mix {
val recipe = recipeService.getById(entity.recipeId)
val materialType = materialTypeService.getById(entity.materialTypeId)
val mixType = mixTypeService.getOrCreateForNameAndMaterialType(entity.name, materialType)
val mixMaterials = if (entity.mixMaterials != null) mixMaterialService.create(entity.mixMaterials) else setOf()
mixMaterialService.validateMixMaterials(mixMaterials)
var mix = mix(recipe = recipe, mixType = mixType, mixMaterials = mixMaterials.toMutableSet())
mix = save(mix)
recipeService.addMix(recipe, mix)
return mix
return savedMix.copy(mixQuantities = savedMix.mixQuantities.copy(mixTypes = savedMixMixTypes))
}
@Transactional
override fun update(entity: MixUpdateDto): Mix {
val mix = getById(entity.id)
if (entity.name != null || entity.materialTypeId != null) {
val name = entity.name ?: mix.mixType.name
val materialType = if (entity.materialTypeId != null)
materialTypeService.getById(entity.materialTypeId)
else
mix.mixType.material.materialType!!
override fun toDto(entity: Mix) =
MixDto(
entity.id,
entity.location,
entity.recipeId,
mixTypeService.toDto(entity.mixType),
MixQuantitiesDto(
entity.mixMaterials.filter { !it.material.isMixType }.map(mixMaterialService::toDto),
mixMixTypeService.getAllByMixId(entity.id)
)
)
mix.mixType = if (mixTypeIsShared(mix.mixType)) {
mixTypeService.saveForNameAndMaterialType(name, materialType)
} else {
mixTypeService.updateForNameAndMaterialType(mix.mixType, name, materialType)
}
}
if (entity.mixMaterials != null) {
mix.mixMaterials.setAll(mixMaterialService.create(entity.mixMaterials!!).toMutableSet())
}
return update(mix)
}
override fun updateLocations(updatedLocations: Collection<MixLocationDto>) {
updatedLocations.forEach(::updateLocation)
}
override fun updateLocation(updatedLocation: MixLocationDto) {
repository.updateLocationById(updatedLocation.mixId, updatedLocation.location)
}
@Transactional
override fun delete(entity: Mix) {
if (!repository.canBeDeleted(entity.id!!)) throw cannotDeleteMixException(entity)
recipeService.removeMix(entity)
super.delete(entity)
}
}
override fun toEntity(dto: MixDto) =
Mix(
dto.id,
dto.location,
dto.recipeId,
mixTypeService.toEntity(dto.mixType),
dto.mixQuantities.materials.map(mixMaterialService::toEntity)
)
}

View File

@ -1,88 +1,52 @@
package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent
import dev.fyloz.colorrecipesexplorer.dtos.MixTypeDto
import dev.fyloz.colorrecipesexplorer.model.MixType
import dev.fyloz.colorrecipesexplorer.repository.MixTypeRepository
import org.springframework.context.annotation.Lazy
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Service
interface MixTypeService : NamedModelService<MixType, MixTypeRepository> {
/** Checks if a [MixType] with the given [name] and [materialType] exists. */
fun existsByNameAndMaterialType(name: String, materialType: MaterialType): Boolean
interface MixTypeService : Service<MixTypeDto, MixType, MixTypeRepository> {
/** Checks if a mix type with the given [name], [materialTypeId] and a different [id] exists. */
fun existsByNameAndMaterialType(name: String, materialTypeId: Long, id: Long? = null): Boolean
/** Gets the mix type with the given [material]. */
fun getByMaterial(material: Material): MixType
/** Finds the mix type with the given [name] and [materialTypeId]. */
fun getByNameAndMaterialType(name: String, materialTypeId: Long): MixTypeDto?
/** Gets the [MixType] with the given [name] and [materialType]. */
fun getByNameAndMaterialType(name: String, materialType: MaterialType): MixType
/** Checks if a mix depends on the mix type with the given [id]. */
fun isUsedByMixes(id: Long): Boolean
/** Returns a [MixType] for the given [name] and [materialType]. If a mix type with these does not already exists, it will be created. */
fun getOrCreateForNameAndMaterialType(name: String, materialType: MaterialType): MixType
/** Returns a new and persisted [MixType] with the given [name] and [materialType]. */
fun saveForNameAndMaterialType(name: String, materialType: MaterialType): MixType
/** Returns the given [mixType] updated with the given [name] and [materialType]. */
fun updateForNameAndMaterialType(mixType: MixType, name: String, materialType: MaterialType): MixType
/** Checks if the mix type with the given [id] is used by more than one mix. */
fun isShared(id: Long): Boolean
}
@Service
@Profile("!emergency")
class MixTypeServiceImpl(
mixTypeRepository: MixTypeRepository,
@Lazy val materialService: MaterialService,
@Lazy val mixService: MixService
@ServiceComponent
class DefaultMixTypeService(
repository: MixTypeRepository,
val materialService: MaterialService,
val materialTypeService: MaterialTypeService
) :
AbstractNamedModelService<MixType, MixTypeRepository>(mixTypeRepository), MixTypeService {
override fun idNotFoundException(id: Long) = mixTypeIdNotFoundException(id)
override fun idAlreadyExistsException(id: Long) = mixTypeIdAlreadyExistsException(id)
override fun nameNotFoundException(name: String) = mixTypeNameNotFoundException(name)
override fun nameAlreadyExistsException(name: String) = mixTypeNameAlreadyExistsException(name)
BaseService<MixTypeDto, MixType, MixTypeRepository>(repository), MixTypeService {
override fun existsByNameAndMaterialType(name: String, materialTypeId: Long, id: Long?) =
repository.existsByNameAndMaterialTypeAndIdNot(name, materialTypeId, id ?: 0L)
override fun existsByNameAndMaterialType(name: String, materialType: MaterialType): Boolean =
repository.existsByNameAndMaterialType(name, materialType)
override fun getByNameAndMaterialType(name: String, materialTypeId: Long) =
repository.findByNameAndMaterialType(name, materialTypeId)?.let(::toDto)
override fun getByMaterial(material: Material): MixType =
repository.findByMaterial(material) ?: throw nameNotFoundException(material.name)
override fun isUsedByMixes(id: Long) = repository.isUsedByMixes(id)
override fun isShared(id: Long) = repository.isShared(id)
override fun getByNameAndMaterialType(name: String, materialType: MaterialType): MixType =
repository.findByNameAndMaterialType(name, materialType)
?: throw MixTypeNameAndMaterialTypeNotFoundException(name, materialType)
override fun getOrCreateForNameAndMaterialType(name: String, materialType: MaterialType): MixType =
if (existsByNameAndMaterialType(name, materialType))
getByNameAndMaterialType(name, materialType)
else
saveForNameAndMaterialType(name, materialType)
override fun save(entity: MixType): MixType {
if (materialService.existsByName(entity.name))
throw materialNameAlreadyExistsException(entity.name)
return super.save(entity)
}
override fun saveForNameAndMaterialType(name: String, materialType: MaterialType): MixType =
save(
mixType(
name = name,
material = material(
name = name,
inventoryQuantity = Float.MIN_VALUE,
isMixType = true,
materialType = materialType
)
override fun toDto(entity: MixType) =
MixTypeDto(
entity.id,
entity.name,
materialTypeService.toDto(entity.materialType),
if (entity.material != null) materialService.toDto(entity.material) else null
)
)
override fun updateForNameAndMaterialType(mixType: MixType, name: String, materialType: MaterialType): MixType =
update(mixType.apply {
this.name = name
material.name = name
material.materialType = materialType
})
override fun delete(entity: MixType) {
if (!repository.canBeDeleted(entity.id!!)) throw cannotDeleteMixTypeException(entity)
super.delete(entity)
}
}
override fun toEntity(dto: MixTypeDto) =
MixType(
dto.id, dto.name,
materialTypeService.toEntity(dto.materialType),
if (dto.material != null) materialService.toEntity(dto.material) else null
)
}

View File

@ -1,260 +1,92 @@
package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.model.account.Group
import dev.fyloz.colorrecipesexplorer.model.validation.or
import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent
import dev.fyloz.colorrecipesexplorer.dtos.RecipeDto
import dev.fyloz.colorrecipesexplorer.dtos.RecipeGroupInformationDto
import dev.fyloz.colorrecipesexplorer.logic.config.ConfigurationLogic
import dev.fyloz.colorrecipesexplorer.model.ConfigurationType
import dev.fyloz.colorrecipesexplorer.model.Recipe
import dev.fyloz.colorrecipesexplorer.model.RecipeGroupInformation
import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository
import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService
import dev.fyloz.colorrecipesexplorer.service.files.WriteableFileService
import dev.fyloz.colorrecipesexplorer.service.users.GroupService
import dev.fyloz.colorrecipesexplorer.utils.setAll
import org.springframework.context.annotation.Lazy
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
import dev.fyloz.colorrecipesexplorer.utils.collections.lazyMap
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDate
import java.time.Period
import javax.transaction.Transactional
interface RecipeService :
ExternalModelService<Recipe, RecipeSaveDto, RecipeUpdateDto, RecipeOutputDto, RecipeRepository> {
/** Checks if one or more recipes have the given [company]. */
fun existsByCompany(company: Company): Boolean
/** Checks if a recipe exists with the given [name] and [company]. */
fun existsByNameAndCompany(name: String, company: Company): Boolean
/** Checks if the approbation date of the given [recipe] is expired. */
fun isApprobationExpired(recipe: Recipe): Boolean?
interface RecipeService : Service<RecipeDto, Recipe, RecipeRepository> {
/** Checks if a recipe with the given [name], [companyId] and a different [id] exists. */
fun existsByNameAndCompany(name: String, companyId: Long, id: Long?): Boolean
/** Gets all recipes with the given [name]. */
fun getAllByName(name: String): Collection<Recipe>
/** Gets all recipes with the given [company]. */
fun getAllByCompany(company: Company): Collection<Recipe>
/** Updates the public data of a recipe with the given [publicDataDto]. */
fun updatePublicData(publicDataDto: RecipePublicDataDto)
/** Adds the given [mix] to the given [recipe]. */
fun addMix(recipe: Recipe, mix: Mix): Recipe
/** Removes the given [mix] from its recipe. */
fun removeMix(mix: Mix): Recipe
fun getAllByName(name: String): Collection<RecipeDto>
}
@Service
@RequireDatabase
class RecipeServiceImpl(
recipeRepository: RecipeRepository,
val companyService: CompanyService,
val mixService: MixService,
val recipeStepService: RecipeStepService,
@Lazy val groupService: GroupService,
val recipeImageService: RecipeImageService,
val configService: ConfigurationService
@ServiceComponent
class DefaultRecipeService(
repository: RecipeRepository,
private val companyService: CompanyService,
private val mixService: MixService,
private val recipeStepService: RecipeStepService,
private val groupService: GroupService,
private val configLogic: ConfigurationLogic
) :
AbstractExternalModelService<Recipe, RecipeSaveDto, RecipeUpdateDto, RecipeOutputDto, RecipeRepository>(
recipeRepository
),
RecipeService {
override fun idNotFoundException(id: Long) = recipeIdNotFoundException(id)
override fun idAlreadyExistsException(id: Long) = recipeIdAlreadyExistsException(id)
BaseService<RecipeDto, Recipe, RecipeRepository>(repository), RecipeService {
override fun existsByNameAndCompany(name: String, companyId: Long, id: Long?) =
repository.existsByNameAndCompanyAndIdNot(name, companyId, id ?: 0L)
override fun Recipe.toOutput() = RecipeOutputDto(
this.id!!,
this.name,
this.description,
this.color,
this.gloss,
this.sample,
this.approbationDate,
isApprobationExpired(this),
this.remark,
this.company,
this.mixes.map {
with(mixService) {
it.toOutput()
}
}.toSet(),
this.groupsInformation,
recipeImageService.getAllImages(this)
.map { this.imageUrl(configService.getContent(ConfigurationType.INSTANCE_URL), it) }
.toSet()
)
override fun getAllByName(name: String) =
repository.findAllByName(name).map(::toDto)
override fun existsByCompany(company: Company): Boolean = repository.existsByCompany(company)
override fun existsByNameAndCompany(name: String, company: Company) =
repository.existsByNameAndCompany(name, company)
@Transactional
override fun toDto(entity: Recipe) =
RecipeDto(
entity.id,
entity.name,
entity.description,
entity.color,
entity.gloss,
entity.sample,
entity.approbationDate,
isApprobationExpired(entity) ?: false,
entity.remark,
companyService.toDto(entity.company),
entity.mixes.lazyMap(mixService::toDto),
entity.groupsInformation.lazyMap(::groupInformationToDto)
)
override fun isApprobationExpired(recipe: Recipe): Boolean? =
with(Period.parse(configService.getContent(ConfigurationType.RECIPE_APPROBATION_EXPIRATION))) {
private fun groupInformationToDto(entity: RecipeGroupInformation) =
RecipeGroupInformationDto(
entity.id,
groupService.toDto(entity.group),
entity.note,
entity.steps?.lazyMap(recipeStepService::toDto) ?: listOf()
)
override fun toEntity(dto: RecipeDto) =
Recipe(
dto.id,
dto.name,
dto.description,
dto.color,
dto.gloss,
dto.sample,
dto.approbationDate,
dto.remark,
companyService.toEntity(dto.company),
dto.mixes.map(mixService::toEntity),
dto.groupsInformation.map(::groupInformationToEntity)
)
private fun groupInformationToEntity(dto: RecipeGroupInformationDto) =
RecipeGroupInformation(
dto.id,
groupService.toEntity(dto.group),
dto.note,
dto.steps.map(recipeStepService::toEntity)
)
private fun isApprobationExpired(recipe: Recipe): Boolean? =
with(Period.parse(configLogic.getContent(ConfigurationType.RECIPE_APPROBATION_EXPIRATION))) {
recipe.approbationDate?.plus(this)?.isBefore(LocalDate.now())
}
override fun getAllByName(name: String) = repository.findAllByName(name)
override fun getAllByCompany(company: Company) = repository.findAllByCompany(company)
override fun save(entity: RecipeSaveDto): Recipe {
val company = companyService.getById(entity.companyId)
if (existsByNameAndCompany(entity.name, company)) {
throw recipeNameAlreadyExistsForCompanyException(entity.name, company)
}
return save(with(entity) {
recipe(
name = name,
description = description,
color = color,
gloss = gloss,
sample = sample,
approbationDate = approbationDate,
remark = remark ?: "",
company = company
)
})
}
@Transactional
override fun update(entity: RecipeUpdateDto): Recipe {
val persistedRecipe = getById(entity.id)
val name = entity.name
val company = persistedRecipe.company
if (name != null &&
name != persistedRecipe.name &&
existsByNameAndCompany(name, company)
) {
throw recipeNameAlreadyExistsForCompanyException(name, company)
}
return update(with(entity) {
recipe(
id = id,
name = name or persistedRecipe.name,
description = description or persistedRecipe.description,
color = color or persistedRecipe.color,
gloss = gloss ?: persistedRecipe.gloss,
sample = sample ?: persistedRecipe.sample,
approbationDate = approbationDate ?: persistedRecipe.approbationDate,
remark = remark or persistedRecipe.remark,
company = company,
mixes = persistedRecipe.mixes,
groupsInformation = updateGroupsInformation(persistedRecipe, entity)
)
})
}
private fun updateGroupsInformation(recipe: Recipe, updateDto: RecipeUpdateDto): Set<RecipeGroupInformation> {
val steps = updateDto.steps ?: return recipe.groupsInformation
val updatedGroupsInformation = mutableSetOf<RecipeGroupInformation>()
steps.forEach {
with(recipe.groupInformationForGroup(it.groupId)) {
// Set steps for the existing RecipeGroupInformation or create a new one
val updatedGroupInformation = this?.apply {
if (this.steps != null) {
this.steps!!.setAll(it.steps)
} else {
this.steps = it.steps.toMutableSet()
}
} ?: recipeGroupInformation(
group = groupService.getById(it.groupId),
steps = it.steps.toMutableSet()
)
updatedGroupsInformation.add(updatedGroupInformation)
recipeStepService.validateGroupInformationSteps(updatedGroupInformation)
}
}
return updatedGroupsInformation
}
@Transactional
override fun updatePublicData(publicDataDto: RecipePublicDataDto) {
if (publicDataDto.notes != null) {
val recipe = getById(publicDataDto.recipeId)
fun noteForGroup(group: Group) =
publicDataDto.notes.firstOrNull { it.groupId == group.id }?.content
// Notes
recipe.groupsInformation.map {
val updatedNote = noteForGroup(it.group)
it.apply {
note = updatedNote
}
}
update(recipe)
}
if (publicDataDto.mixesLocation != null) {
mixService.updateLocations(publicDataDto.mixesLocation)
}
}
override fun addMix(recipe: Recipe, mix: Mix) =
update(recipe.apply { mixes.add(mix) })
override fun removeMix(mix: Mix): Recipe =
update(mix.recipe.apply { mixes.remove(mix) })
}
interface RecipeImageService {
/** Gets the name of every images associated to the recipe with the given [recipe]. */
fun getAllImages(recipe: Recipe): Set<String>
/** Saves the given [image] and associate it to the recipe with the given [recipe]. Returns the name of the saved image. */
fun download(image: MultipartFile, recipe: Recipe): String
/** Deletes the image with the given [name] for the given [recipe]. */
fun delete(recipe: Recipe, name: String)
}
const val RECIPE_IMAGE_ID_DELIMITER = "_"
const val RECIPE_IMAGE_EXTENSION = ".jpg"
@Service
@RequireDatabase
class RecipeImageServiceImpl(
val fileService: WriteableFileService
) : RecipeImageService {
override fun getAllImages(recipe: Recipe) =
fileService.listDirectoryFiles(recipe.imagesDirectoryPath)
.map { it.name }
.toSet()
override fun download(image: MultipartFile, recipe: Recipe): String {
/** Gets the next id available for a new image for the given [recipe]. */
fun getNextAvailableId(): Long =
with(getAllImages(recipe)) {
if (isEmpty())
0
else
maxOf {
it.split(RECIPE_IMAGE_ID_DELIMITER)
.last()
.replace(RECIPE_IMAGE_EXTENSION, "")
.toLong()
} + 1L
}
return getImageFileName(recipe, getNextAvailableId()).also {
with(getImagePath(recipe, it)) {
fileService.writeToDirectory(image, this, recipe.imagesDirectoryPath, true)
}
}
}
override fun delete(recipe: Recipe, name: String) =
fileService.deleteFromDirectory(getImagePath(recipe, name), recipe.imagesDirectoryPath)
private fun getImageFileName(recipe: Recipe, id: Long) =
"${recipe.name}$RECIPE_IMAGE_ID_DELIMITER$id"
private fun getImagePath(recipe: Recipe, name: String) =
"${recipe.imagesDirectoryPath}/$name$RECIPE_IMAGE_EXTENSION"
}
}

View File

@ -1,124 +1,18 @@
package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.exception.RestException
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.model.account.Group
import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent
import dev.fyloz.colorrecipesexplorer.dtos.RecipeStepDto
import dev.fyloz.colorrecipesexplorer.model.RecipeStep
import dev.fyloz.colorrecipesexplorer.repository.RecipeStepRepository
import dev.fyloz.colorrecipesexplorer.utils.findDuplicated
import dev.fyloz.colorrecipesexplorer.utils.hasGaps
import org.springframework.context.annotation.Profile
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
interface RecipeStepService : ModelService<RecipeStep, RecipeStepRepository> {
/** Validates the steps of the given [groupInformation], according to the criteria of [validateSteps]. */
fun validateGroupInformationSteps(groupInformation: RecipeGroupInformation)
interface RecipeStepService : Service<RecipeStepDto, RecipeStep, RecipeStepRepository>
/**
* Validates if the given [steps]. To be valid, the position of each step must be greater or equals to 1 and unique in the set.
* There must also be no gap between the positions.
* If any of those criteria are not met, an [InvalidGroupStepsPositionsException] will be thrown.
*/
fun validateSteps(steps: Set<RecipeStep>)
}
@ServiceComponent
class DefaultRecipeStepService(repository: RecipeStepRepository) :
BaseService<RecipeStepDto, RecipeStep, RecipeStepRepository>(repository), RecipeStepService {
override fun toDto(entity: RecipeStep) =
RecipeStepDto(entity.id, entity.position, entity.message)
@Service
@Profile("!emergency")
class RecipeStepServiceImpl(recipeStepRepository: RecipeStepRepository) :
AbstractModelService<RecipeStep, RecipeStepRepository>(recipeStepRepository),
RecipeStepService {
override fun idNotFoundException(id: Long) = recipeStepIdNotFoundException(id)
override fun idAlreadyExistsException(id: Long) = recipeStepIdAlreadyExistsException(id)
override fun validateGroupInformationSteps(groupInformation: RecipeGroupInformation) {
if (groupInformation.steps == null) return
try {
validateSteps(groupInformation.steps!!)
} catch (validationException: InvalidStepsPositionsException) {
throw InvalidGroupStepsPositionsException(groupInformation.group, validationException)
}
}
override fun validateSteps(steps: Set<RecipeStep>) {
if (steps.isEmpty()) return
val sortedSteps = steps.sortedBy { it.position }
val errors = mutableSetOf<InvalidStepsPositionsError>()
// Check if the first step position is 1
fun isFirstStepPositionInvalid() =
sortedSteps[0].position != 1
// Check if any position is duplicated
fun getDuplicatedPositionsErrors() =
sortedSteps
.findDuplicated { it.position }
.map { duplicatedStepsPositions(it) }
// Find all errors and throw if there is any
if (isFirstStepPositionInvalid()) errors += invalidFirstStepPosition(sortedSteps[0])
errors += getDuplicatedPositionsErrors()
if (errors.isEmpty() && steps.hasGaps { it.position }) errors += gapBetweenStepsPositions()
if (errors.isNotEmpty()) {
throw InvalidStepsPositionsException(errors)
}
}
}
data class InvalidStepsPositionsError(
val type: String,
val details: String
)
class InvalidStepsPositionsException(
val errors: Set<InvalidStepsPositionsError>
) : RestException(
"invalid-recipestep-position",
"Invalid steps positions",
HttpStatus.BAD_REQUEST,
"The position of steps are invalid",
mapOf(
"invalidSteps" to errors
)
)
class InvalidGroupStepsPositionsException(
val group: Group,
val exception: InvalidStepsPositionsException
) : RestException(
"invalid-groupinformation-recipestep-position",
"Invalid steps positions",
HttpStatus.BAD_REQUEST,
"The position of steps for the group ${group.name} are invalid",
mapOf(
"group" to group.name,
"groupId" to group.id!!,
"invalidSteps" to exception.errors
)
) {
val errors: Set<InvalidStepsPositionsError>
get() = exception.errors
}
const val INVALID_FIRST_STEP_POSITION_ERROR_CODE = "first"
const val DUPLICATED_STEPS_POSITIONS_ERROR_CODE = "duplicated"
const val GAP_BETWEEN_STEPS_POSITIONS_ERROR_CODE = "gap"
private fun invalidFirstStepPosition(step: RecipeStep) =
InvalidStepsPositionsError(
INVALID_FIRST_STEP_POSITION_ERROR_CODE,
"The position ${step.position} is under the minimum of 1"
)
private fun duplicatedStepsPositions(position: Int) =
InvalidStepsPositionsError(
DUPLICATED_STEPS_POSITIONS_ERROR_CODE,
"The position $position is duplicated"
)
private fun gapBetweenStepsPositions() =
InvalidStepsPositionsError(
GAP_BETWEEN_STEPS_POSITIONS_ERROR_CODE,
"There is a gap between steps positions"
)
override fun toEntity(dto: RecipeStepDto) =
RecipeStep(dto.id, dto.position, dto.message)
}

View File

@ -1,182 +1,67 @@
package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import dev.fyloz.colorrecipesexplorer.model.EntityDto
import dev.fyloz.colorrecipesexplorer.model.Model
import dev.fyloz.colorrecipesexplorer.model.NamedModel
import dev.fyloz.colorrecipesexplorer.repository.NamedJpaRepository
import io.jsonwebtoken.lang.Assert
import dev.fyloz.colorrecipesexplorer.dtos.EntityDto
import dev.fyloz.colorrecipesexplorer.model.ModelEntity
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.repository.findByIdOrNull
/**
* A service implementing the basics CRUD operations for the given entities.
* Represents a service between the logic and the repository.
* Gives access to the repository using a DTO.
*
* @param E The entity type
* @param R The entity repository type
* @param D The type of the entity DTO.
* @param E The type of the entity.
* @param R The repository of the entity.
*/
interface Service<E, R : JpaRepository<E, *>> {
val repository: R
/** Gets all entities. */
fun getAll(): Collection<E>
/** Saves a given [entity]. */
fun save(entity: E): E
/** Updates a given [entity]. */
fun update(entity: E): E
/** Deletes a given [entity]. */
fun delete(entity: E)
}
/** A service for entities implementing the [Model] interface. This service add supports for numeric identifiers. */
interface ModelService<E : Model, R : JpaRepository<E, *>> : Service<E, R> {
interface Service<D : EntityDto, E : ModelEntity, R : JpaRepository<E, Long>> {
/** Checks if an entity with the given [id] exists. */
fun existsById(id: Long): Boolean
/** Gets the entity with the given [id]. */
fun getById(id: Long): E
/** Gets all entities as DTOs. */
fun getAll(): Collection<D>
/** Gets the entity DTO with the given [id].*/
fun getById(id: Long): D?
/** Saves the given [dto]. */
fun save(dto: D): D
/** Deletes the given [dto]. */
fun delete(dto: D)
/** Deletes the entity with the given [id]. */
fun deleteById(id: Long)
/** Converts the given [entity] to a DTO. */
fun toDto(entity: E): D
/** Converts the given [dto] to an entity. */
fun toEntity(dto: D): E
}
/** A service for entities implementing the [NamedModel] interface. This service add supports for name identifiers. */
interface NamedModelService<E : NamedModel, R : JpaRepository<E, *>> : ModelService<E, R> {
/** Checks if an entity with the given [name] exists. */
fun existsByName(name: String): Boolean
abstract class BaseService<D : EntityDto, E : ModelEntity, R : JpaRepository<E, Long>>(protected val repository: R) :
Service<D, E, R> {
override fun existsById(id: Long) =
repository.existsById(id)
/** Gets the entity with the given [name]. */
fun getByName(name: String): E
}
override fun getAll() =
repository.findAll().map(this::toDto)
abstract class AbstractService<E, R : JpaRepository<E, *>>(override val repository: R) : Service<E, R> {
override fun getAll(): Collection<E> = repository.findAll()
override fun save(entity: E): E = repository.save(entity)
override fun update(entity: E): E = repository.save(entity)
override fun delete(entity: E) = repository.delete(entity)
}
abstract class AbstractModelService<E : Model, R : JpaRepository<E, Long>>(repository: R) :
AbstractService<E, R>(repository), ModelService<E, R> {
protected abstract fun idNotFoundException(id: Long): NotFoundException
protected abstract fun idAlreadyExistsException(id: Long): AlreadyExistsException
override fun existsById(id: Long): Boolean = repository.existsById(id)
override fun getById(id: Long): E = repository.findByIdOrNull(id) ?: throw idNotFoundException(id)
override fun save(entity: E): E {
if (entity.id != null && existsById(entity.id!!))
throw idAlreadyExistsException(entity.id!!)
return super.save(entity)
override fun getById(id: Long): D? {
val entity = repository.findByIdOrNull(id) ?: return null
return toDto(entity)
}
override fun update(entity: E): E {
assertId(entity.id)
if (!existsById(entity.id!!))
throw idNotFoundException(entity.id!!)
return super.update(entity)
override fun save(dto: D): D {
val entity = repository.save(toEntity(dto))
return toDto(entity)
}
override fun deleteById(id: Long) =
delete(getById(id)) // Use delete(entity) to prevent code duplication and to ease testing
protected fun assertId(id: Long?) {
Assert.notNull(id, "${javaClass.simpleName}.update() was called with a null identifier")
}
}
abstract class AbstractNamedModelService<E : NamedModel, R : NamedJpaRepository<E>>(repository: R) :
AbstractModelService<E, R>(repository), NamedModelService<E, R> {
protected abstract fun nameNotFoundException(name: String): NotFoundException
protected abstract fun nameAlreadyExistsException(name: String): AlreadyExistsException
override fun existsByName(name: String): Boolean = repository.existsByName(name)
override fun getByName(name: String): E = repository.findByName(name) ?: throw nameNotFoundException(name)
override fun save(entity: E): E {
if (existsByName(entity.name))
throw nameAlreadyExistsException(entity.name)
return super.save(entity)
override fun delete(dto: D) {
repository.delete(toEntity(dto))
}
override fun update(entity: E): E {
assertId(entity.id)
assertName(entity.name)
with(repository.findByName(entity.name)) {
if (this != null && id != entity.id)
throw nameAlreadyExistsException(entity.name)
}
return super.update(entity)
override fun deleteById(id: Long) {
repository.deleteById(id)
}
private fun assertName(name: String) {
Assert.notNull(name, "${javaClass.simpleName}.update() was called with a null name")
}
}
/**
* A service that will receive *external* interactions, from the REST API, for example.
*
* @param E The entity type
* @param S The entity save DTO type
* @param U The entity update DTO type
*/
interface ExternalService<E, S : EntityDto<E>, U : EntityDto<E>, O, R : JpaRepository<E, *>> : Service<E, R> {
/** Gets all entities mapped to their output model. */
fun getAllForOutput(): Collection<O>
/** Saves a given [entity]. */
fun save(entity: S): E = save(entity.toEntity())
/** Updates a given [entity]. */
fun update(entity: U): E
/** Convert the given entity to its output model. */
fun E.toOutput(): O
}
/** An [ExternalService] for entities implementing the [Model] interface. */
interface ExternalModelService<E : Model, S : EntityDto<E>, U : EntityDto<E>, O, R : JpaRepository<E, *>> :
ModelService<E, R>, ExternalService<E, S, U, O, R> {
/** Gets the entity with the given [id] mapped to its output model. */
fun getByIdForOutput(id: Long): O
}
/** An [ExternalService] for entities implementing the [NamedModel] interface. */
interface ExternalNamedModelService<E : NamedModel, S : EntityDto<E>, U : EntityDto<E>, O, R : JpaRepository<E, *>> :
NamedModelService<E, R>, ExternalModelService<E, S, U, O, R>
/** An [AbstractService] with the functionalities of a [ExternalService]. */
@Suppress("unused")
abstract class AbstractExternalService<E, S : EntityDto<E>, U : EntityDto<E>, O, R : JpaRepository<E, *>>(repository: R) :
AbstractService<E, R>(repository), ExternalService<E, S, U, O, R> {
override fun getAllForOutput() =
getAll().map { it.toOutput() }
}
/** An [AbstractModelService] with the functionalities of a [ExternalService]. */
abstract class AbstractExternalModelService<E : Model, S : EntityDto<E>, U : EntityDto<E>, O, R : JpaRepository<E, Long>>(
repository: R
) : AbstractModelService<E, R>(repository), ExternalModelService<E, S, U, O, R> {
override fun getAllForOutput() =
getAll().map { it.toOutput() }
override fun getByIdForOutput(id: Long) =
getById(id).toOutput()
}
/** An [AbstractNamedModelService] with the functionalities of a [ExternalService]. */
abstract class AbstractExternalNamedModelService<E : NamedModel, S : EntityDto<E>, U : EntityDto<E>, O, R : NamedJpaRepository<E>>(
repository: R
) : AbstractNamedModelService<E, R>(repository), ExternalNamedModelService<E, S, U, O, R> {
override fun getAllForOutput() =
getAll().map { it.toOutput() }
override fun getByIdForOutput(id: Long) =
getById(id).toOutput()
}
}

Some files were not shown because too many files have changed in this diff Show More