diff --git a/api/src/main/kotlin/handler/TimetableThemeHandler.kt b/api/src/main/kotlin/handler/TimetableThemeHandler.kt index e4e40cc9..ba61b24e 100644 --- a/api/src/main/kotlin/handler/TimetableThemeHandler.kt +++ b/api/src/main/kotlin/handler/TimetableThemeHandler.kt @@ -71,4 +71,11 @@ class TimetableThemeHandler( timetableThemeService.unsetDefault(userId, themeId).let(::TimetableThemeDto) } + + suspend fun unsetBasicThemeTypeDefault(req: ServerRequest) = handle(req) { + val userId = req.userId + val basicThemeType = req.pathVariable("basicThemeTypeValue").toIntOrNull()?.let { BasicThemeType.from(it) } ?: throw InvalidPathParameterException("basicThemeTypeValue") + + timetableThemeService.unsetDefault(userId, basicThemeType = basicThemeType).let(::TimetableThemeDto) + } } diff --git a/api/src/main/kotlin/router/MainRouter.kt b/api/src/main/kotlin/router/MainRouter.kt index eff7d2ef..faba3779 100644 --- a/api/src/main/kotlin/router/MainRouter.kt +++ b/api/src/main/kotlin/router/MainRouter.kt @@ -189,6 +189,7 @@ class MainRouter( POST("{themeId}/default", timetableThemeHandler::setDefault) POST("basic/{basicThemeTypeValue}/default", timetableThemeHandler::setBasicThemeTypeDefault) DELETE("{themeId}/default", timetableThemeHandler::unsetDefault) + DELETE("basic/{basicThemeTypeValue}/default", timetableThemeHandler::unsetBasicThemeTypeDefault) } } diff --git a/api/src/main/kotlin/router/docs/ThemeDocs.kt b/api/src/main/kotlin/router/docs/ThemeDocs.kt index c6f855a9..45e1ad1d 100644 --- a/api/src/main/kotlin/router/docs/ThemeDocs.kt +++ b/api/src/main/kotlin/router/docs/ThemeDocs.kt @@ -87,5 +87,13 @@ import org.springframework.web.bind.annotation.RequestMethod responses = [ApiResponse(responseCode = "200", content = [Content(schema = Schema(implementation = TimetableThemeDto::class))])], ), ), + RouterOperation( + path = "/v1/themes/basic/{basicThemeTypeValue}/default", method = [RequestMethod.DELETE], produces = [MediaType.APPLICATION_JSON_VALUE], + operation = Operation( + operationId = "unsetBasicThemeTypeDefault", + parameters = [Parameter(`in` = ParameterIn.PATH, name = "basicThemeTypeValue", required = true)], + responses = [ApiResponse(responseCode = "200", content = [Content(schema = Schema(implementation = TimetableThemeDto::class))])], + ), + ), ) annotation class ThemeDocs diff --git a/core/src/main/kotlin/common/exception/ErrorType.kt b/core/src/main/kotlin/common/exception/ErrorType.kt index a9f53de5..7f34e9f4 100644 --- a/core/src/main/kotlin/common/exception/ErrorType.kt +++ b/core/src/main/kotlin/common/exception/ErrorType.kt @@ -24,7 +24,7 @@ enum class ErrorType( INVALID_PASSWORD(HttpStatus.FORBIDDEN, 0x3001, "password가 유효하지 않습니다.", "비밀번호는 6~20자로 영문자와 숫자를 모두 포함해야 합니다."), DUPLICATE_LOCAL_ID(HttpStatus.FORBIDDEN, 0x3002, "localId가 중복되었습니다.", "이미 사용 중인 아이디입니다."), DUPLICATE_TIMETABLE_TITLE(HttpStatus.FORBIDDEN, 0x3003, "timetable title이 중복되었습니다.", "이미 사용중인 시간표 이름입니다."), - DUPLICATE_LECTURE(HttpStatus.FORBIDDEN, 0x3004, "duplicate lecture"), + DUPLICATE_LECTURE(HttpStatus.FORBIDDEN, 0x3004, "duplicate lecture", "duplicate lecture"), WRONG_SEMESTER(HttpStatus.FORBIDDEN, 0x300A, "잘못된 학기입니다."), LECTURE_TIME_OVERLAP(HttpStatus.FORBIDDEN, 0x300C, "강의 시간이 겹칩니다."), CANNOT_RESET_CUSTOM_LECTURE(HttpStatus.FORBIDDEN, 0x300D, "cannot reset custom lectures"), @@ -44,6 +44,8 @@ enum class ErrorType( TABLE_DELETE_ERROR(HttpStatus.BAD_REQUEST, 40010, "하나 남은 시간표는 삭제할 수 없습니다."), TIMETABLE_NOT_PRIMARY(HttpStatus.BAD_REQUEST, 40011, "대표 시간표가 아닙니다."), INVALID_THEME_COLOR_COUNT(HttpStatus.BAD_REQUEST, 40012, "테마의 색상 개수가 적절하지 않습니다.", "테마의 색상 개수가 적절하지 않습니다."), + DEFAULT_THEME_DELETE_ERROR(HttpStatus.BAD_REQUEST, 40013, "default 테마는 삭제할 수 없습니다.", "default 테마는 삭제할 수 없습니다."), + NOT_DEFAULT_THEME_ERROR(HttpStatus.BAD_REQUEST, 40014, "default 테마가 아닙니다.", "default 테마가 아닙니다."), TIMETABLE_NOT_FOUND(HttpStatus.NOT_FOUND, 40400, "timetable_id가 유효하지 않습니다", "존재하지 않는 시간표입니다."), PRIMARY_TIMETABLE_NOT_FOUND(HttpStatus.NOT_FOUND, 40401, "timetable_id가 유효하지 않습니다", "대표 시간표가 존재하지 않습니다."), diff --git a/core/src/main/kotlin/common/exception/Snu4tException.kt b/core/src/main/kotlin/common/exception/Snu4tException.kt index ae00a2fb..e9f000df 100644 --- a/core/src/main/kotlin/common/exception/Snu4tException.kt +++ b/core/src/main/kotlin/common/exception/Snu4tException.kt @@ -43,6 +43,8 @@ object InvalidNicknameException : Snu4tException(ErrorType.INVALID_NICKNAME) object InvalidDisplayNameException : Snu4tException(ErrorType.INVALID_DISPLAY_NAME) object TableDeleteErrorException : Snu4tException(ErrorType.TABLE_DELETE_ERROR) object InvalidThemeColorCountException : Snu4tException(ErrorType.INVALID_THEME_COLOR_COUNT) +object DefaultThemeDeleteErrorException : Snu4tException(ErrorType.DEFAULT_THEME_DELETE_ERROR) +object NotDefaultThemeErrorException : Snu4tException(ErrorType.NOT_DEFAULT_THEME_ERROR) object NoUserFcmKeyException : Snu4tException(ErrorType.NO_USER_FCM_KEY) object InvalidRegistrationForPreviousSemesterCourseException : diff --git a/core/src/main/kotlin/timetables/repository/TimetableRepository.kt b/core/src/main/kotlin/timetables/repository/TimetableRepository.kt index 2669c384..889febdf 100644 --- a/core/src/main/kotlin/timetables/repository/TimetableRepository.kt +++ b/core/src/main/kotlin/timetables/repository/TimetableRepository.kt @@ -15,4 +15,5 @@ interface TimetableRepository : CoroutineCrudRepository, Time suspend fun findByUserIdAndYearAndSemesterAndIsPrimaryTrue(userId: String, year: Int, semester: Semester): Timetable? suspend fun existsByUserIdAndYearAndSemesterAndTitle(userId: String, year: Int, semester: Semester, title: String): Boolean suspend fun countAllByUserId(userId: String): Long + suspend fun findByUserIdAndThemeId(userId: String, themeId: String): List } diff --git a/core/src/main/kotlin/timetables/service/TimetableLectureService.kt b/core/src/main/kotlin/timetables/service/TimetableLectureService.kt index 02387f83..2558b7e7 100644 --- a/core/src/main/kotlin/timetables/service/TimetableLectureService.kt +++ b/core/src/main/kotlin/timetables/service/TimetableLectureService.kt @@ -14,7 +14,6 @@ import com.wafflestudio.snu4t.timetables.data.TimetableLecture import com.wafflestudio.snu4t.timetables.dto.request.CustomTimetableLectureAddLegacyRequestDto import com.wafflestudio.snu4t.timetables.dto.request.TimetableLectureModifyLegacyRequestDto import com.wafflestudio.snu4t.timetables.repository.TimetableRepository -import com.wafflestudio.snu4t.timetables.utils.ColorUtils import org.springframework.stereotype.Service interface TimetableLectureService { @@ -54,12 +53,7 @@ class TimetableLectureServiceImpl( val lecture = lectureRepository.findById(lectureId) ?: throw LectureNotFoundException if (!(timetable.year == lecture.year && timetable.semester == lecture.semester)) throw WrongSemesterException - val (colorIndex, color) = if (timetable.themeId == null) { - ColorUtils.getLeastUsedColorIndexByRandom(timetable.lectures.map { it.colorIndex }) to null - } else { - val theme = timetableThemeService.getTheme(userId, timetable.themeId) - 0 to requireNotNull(theme.colors).random() - } + val (colorIndex, color) = timetableThemeService.getNewColorIndexAndColor(timetable) if (timetable.lectures.any { it.lectureId == lectureId }) throw DuplicateTimetableLectureException val timetableLecture = TimetableLecture(lecture, colorIndex, color) diff --git a/core/src/main/kotlin/timetables/service/TimetableThemeService.kt b/core/src/main/kotlin/timetables/service/TimetableThemeService.kt index 968a5122..c56e2dbf 100644 --- a/core/src/main/kotlin/timetables/service/TimetableThemeService.kt +++ b/core/src/main/kotlin/timetables/service/TimetableThemeService.kt @@ -1,13 +1,18 @@ package com.wafflestudio.snu4t.timetables.service import com.wafflestudio.snu4t.common.enum.BasicThemeType +import com.wafflestudio.snu4t.common.exception.DefaultThemeDeleteErrorException import com.wafflestudio.snu4t.common.exception.DuplicateThemeNameException import com.wafflestudio.snu4t.common.exception.InvalidThemeColorCountException import com.wafflestudio.snu4t.common.exception.InvalidThemeTypeException +import com.wafflestudio.snu4t.common.exception.NotDefaultThemeErrorException import com.wafflestudio.snu4t.common.exception.ThemeNotFoundException import com.wafflestudio.snu4t.timetables.data.ColorSet +import com.wafflestudio.snu4t.timetables.data.Timetable import com.wafflestudio.snu4t.timetables.data.TimetableTheme +import com.wafflestudio.snu4t.timetables.repository.TimetableRepository import com.wafflestudio.snu4t.timetables.repository.TimetableThemeRepository +import kotlinx.coroutines.flow.collect import org.springframework.stereotype.Service import java.time.LocalDateTime @@ -24,16 +29,19 @@ interface TimetableThemeService { suspend fun setDefault(userId: String, themeId: String? = null, basicThemeType: BasicThemeType? = null): TimetableTheme - suspend fun unsetDefault(userId: String, themeId: String): TimetableTheme + suspend fun unsetDefault(userId: String, themeId: String? = null, basicThemeType: BasicThemeType? = null): TimetableTheme suspend fun getDefaultTheme(userId: String): TimetableTheme? suspend fun getTheme(userId: String, themeId: String? = null, basicThemeType: BasicThemeType? = null): TimetableTheme + + suspend fun getNewColorIndexAndColor(timetable: Timetable): Pair } @Service class TimetableThemeServiceImpl( private val timetableThemeRepository: TimetableThemeRepository, + private val timetableRepository: TimetableRepository, ) : TimetableThemeService { companion object { private const val MAX_COLOR_COUNT = 9 @@ -71,13 +79,40 @@ class TimetableThemeServiceImpl( if (theme.name != it && timetableThemeRepository.existsByUserIdAndName(userId, it)) throw DuplicateThemeNameException theme.name = it } - colors?.let { theme.colors = it } + + colors?.let { + if (it.size !in 1..MAX_COLOR_COUNT) throw InvalidThemeColorCountException + + val colorMap = requireNotNull(theme.colors).mapIndexed { i, color -> color to colors[i] }.toMap() + + val timetables = timetableRepository.findByUserIdAndThemeId(userId, themeId) + timetables.forEach { timetable -> + timetable.lectures.forEach { lecture -> + if (lecture.color in theme.colors!!) { + colorMap[lecture.color]?.let { newColor -> lecture.color = newColor } + } + } + } + timetableRepository.saveAll(timetables).collect() + + theme.colors = it + } theme.updatedAt = LocalDateTime.now() return timetableThemeRepository.save(theme) } override suspend fun deleteTheme(userId: String, themeId: String) { val theme = getCustomTheme(userId, themeId) + if (theme.isDefault) throw DefaultThemeDeleteErrorException + + val timetables = timetableRepository.findByUserIdAndThemeId(userId, themeId) + + timetables.forEach { + it.theme = BasicThemeType.SNUTT + it.themeId = null + } + timetableRepository.saveAll(timetables).collect() + timetableThemeRepository.delete(theme) } @@ -126,9 +161,16 @@ class TimetableThemeServiceImpl( return timetableThemeRepository.save(theme) } - override suspend fun unsetDefault(userId: String, themeId: String): TimetableTheme { - val theme = timetableThemeRepository.findByIdAndUserId(themeId, userId) ?: throw ThemeNotFoundException - if (!theme.isDefault) return theme + override suspend fun unsetDefault(userId: String, themeId: String?, basicThemeType: BasicThemeType?): TimetableTheme { + require((themeId == null) xor (basicThemeType == null)) + + val theme = getDefaultTheme(userId) ?: throw NotDefaultThemeErrorException + + themeId?.let { + if (theme.isCustom.not() || theme.id != it) throw NotDefaultThemeErrorException + } ?: run { + if (theme.isCustom || theme.name != basicThemeType!!.displayName) throw NotDefaultThemeErrorException + } if (theme.isCustom) { theme.isDefault = false @@ -169,6 +211,24 @@ class TimetableThemeServiceImpl( isCustom = false, isDefault = isDefault, ) + + override suspend fun getNewColorIndexAndColor(timetable: Timetable): Pair { + return if (timetable.themeId == null) { + val alreadyUsedIndexes = timetable.lectures.map { it.colorIndex } + val indexToCount = (1..MAX_COLOR_COUNT).associateWith { colorIndex -> alreadyUsedIndexes.count { it == colorIndex } } + + val minCount = indexToCount.minOf { it.value } + indexToCount.entries.filter { (_, count) -> count == minCount }.map { it.key }.random() to null + } else { + val theme = getTheme(timetable.userId, timetable.themeId) + + val alreadyUsedColors = timetable.lectures.map { requireNotNull(it.color) } + val colorToCount = requireNotNull(theme.colors).associateWith { color -> alreadyUsedColors.count { it == color } } + + val minCount = colorToCount.minOf { it.value } + 0 to colorToCount.entries.filter { (_, count) -> count == minCount }.map { it.key }.random() + } + } } fun TimetableTheme?.toBasicThemeType() = if (this == null || isCustom) BasicThemeType.SNUTT else requireNotNull(BasicThemeType.from(name)) diff --git a/core/src/main/kotlin/timetables/utils/ColorUtils.kt b/core/src/main/kotlin/timetables/utils/ColorUtils.kt deleted file mode 100644 index b36b2bca..00000000 --- a/core/src/main/kotlin/timetables/utils/ColorUtils.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.wafflestudio.snu4t.timetables.utils - -object ColorUtils { - const val MAX_COLOR_INDEX: Int = 9 - - fun getLeastUsedColorIndexByRandom(alreadyUsedIndexes: Iterable): Int { - val colorCount = (alreadyUsedIndexes + (1..MAX_COLOR_INDEX)).groupingBy { it }.eachCount() - val minCount = colorCount.minOf { it.value } - return colorCount.entries.filter { (color, count) -> count == minCount }.map { it.key }.random() - } -}