diff --git a/api/src/main/kotlin/handler/TimetableHandler.kt b/api/src/main/kotlin/handler/TimetableHandler.kt index 85ae21a1..915a019e 100644 --- a/api/src/main/kotlin/handler/TimetableHandler.kt +++ b/api/src/main/kotlin/handler/TimetableHandler.kt @@ -97,9 +97,9 @@ class TimetableHandler( suspend fun modifyTimetableTheme(req: ServerRequest): ServerResponse = handle(req) { val userId = req.userId val timetableId = req.pathVariable("timetableId") - val theme = req.awaitBody().theme + val body = req.awaitBody() - timetableService.modifyTimetableTheme(userId, timetableId, theme).let(::TimetableLegacyDto) + timetableService.modifyTimetableTheme(userId, timetableId, body.theme, body.themeId).let(::TimetableLegacyDto) } suspend fun setPrimary(req: ServerRequest): ServerResponse = handle(req) { diff --git a/api/src/main/kotlin/handler/TimetableThemeHandler.kt b/api/src/main/kotlin/handler/TimetableThemeHandler.kt new file mode 100644 index 00000000..e4e40cc9 --- /dev/null +++ b/api/src/main/kotlin/handler/TimetableThemeHandler.kt @@ -0,0 +1,74 @@ +package com.wafflestudio.snu4t.handler + +import com.wafflestudio.snu4t.common.enum.BasicThemeType +import com.wafflestudio.snu4t.common.exception.InvalidPathParameterException +import com.wafflestudio.snu4t.middleware.SnuttRestApiDefaultMiddleware +import com.wafflestudio.snu4t.timetables.dto.TimetableThemeDto +import com.wafflestudio.snu4t.timetables.dto.request.TimetableThemeAddRequestDto +import com.wafflestudio.snu4t.timetables.dto.request.TimetableThemeModifyRequestDto +import com.wafflestudio.snu4t.timetables.service.TimetableThemeService +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.server.ServerRequest +import org.springframework.web.reactive.function.server.awaitBody + +@Component +class TimetableThemeHandler( + private val timetableThemeService: TimetableThemeService, + snuttRestApiDefaultMiddleware: SnuttRestApiDefaultMiddleware, +) : ServiceHandler(snuttRestApiDefaultMiddleware) { + suspend fun getThemes(req: ServerRequest) = handle(req) { + val userId = req.userId + + timetableThemeService.getThemes(userId).map(::TimetableThemeDto) + } + + suspend fun addTheme(req: ServerRequest) = handle(req) { + val userId = req.userId + val body = req.awaitBody() + + timetableThemeService.addTheme(userId, body.name, body.colors).let(::TimetableThemeDto) + } + + suspend fun modifyTheme(req: ServerRequest) = handle(req) { + val userId = req.userId + val themeId = req.pathVariable("themeId") + val body = req.awaitBody() + + timetableThemeService.modifyTheme(userId, themeId, body.name, body.colors).let(::TimetableThemeDto) + } + + suspend fun deleteTheme(req: ServerRequest) = handle(req) { + val userId = req.userId + val themeId = req.pathVariable("themeId") + + timetableThemeService.deleteTheme(userId, themeId) + } + + suspend fun copyTheme(req: ServerRequest) = handle(req) { + val userId = req.userId + val themeId = req.pathVariable("themeId") + + timetableThemeService.copyTheme(userId, themeId).let(::TimetableThemeDto) + } + + suspend fun setDefault(req: ServerRequest) = handle(req) { + val userId = req.userId + val themeId = req.pathVariable("themeId") + + timetableThemeService.setDefault(userId, themeId).let(::TimetableThemeDto) + } + + suspend fun setBasicThemeTypeDefault(req: ServerRequest) = handle(req) { + val userId = req.userId + val basicThemeType = req.pathVariable("basicThemeTypeValue").toIntOrNull()?.let { BasicThemeType.from(it) } ?: throw InvalidPathParameterException("basicThemeTypeValue") + + timetableThemeService.setDefault(userId, basicThemeType = basicThemeType).let(::TimetableThemeDto) + } + + suspend fun unsetDefault(req: ServerRequest) = handle(req) { + val userId = req.userId + val themeId = req.pathVariable("themeId") + + timetableThemeService.unsetDefault(userId, themeId).let(::TimetableThemeDto) + } +} diff --git a/api/src/main/kotlin/router/MainRouter.kt b/api/src/main/kotlin/router/MainRouter.kt index 8b478576..eff7d2ef 100644 --- a/api/src/main/kotlin/router/MainRouter.kt +++ b/api/src/main/kotlin/router/MainRouter.kt @@ -11,6 +11,7 @@ import com.wafflestudio.snu4t.handler.LectureSearchHandler import com.wafflestudio.snu4t.handler.NotificationHandler import com.wafflestudio.snu4t.handler.TimetableHandler import com.wafflestudio.snu4t.handler.TimetableLectureHandler +import com.wafflestudio.snu4t.handler.TimetableThemeHandler import com.wafflestudio.snu4t.handler.UserHandler import com.wafflestudio.snu4t.handler.VacancyNotifcationHandler import com.wafflestudio.snu4t.router.docs.AdminDocs @@ -20,6 +21,7 @@ import com.wafflestudio.snu4t.router.docs.ConfigDocs import com.wafflestudio.snu4t.router.docs.FriendDocs import com.wafflestudio.snu4t.router.docs.LectureSearchDocs import com.wafflestudio.snu4t.router.docs.NotificationDocs +import com.wafflestudio.snu4t.router.docs.ThemeDocs import com.wafflestudio.snu4t.router.docs.TimetableDocs import com.wafflestudio.snu4t.router.docs.UserDocs import com.wafflestudio.snu4t.router.docs.VacancyNotificationDocs @@ -39,6 +41,7 @@ class MainRouter( private val vacancyNotificationHandler: VacancyNotifcationHandler, private val timeTableHandler: TimetableHandler, private val timeTableLectureHandler: TimetableLectureHandler, + private val timetableThemeHandler: TimetableThemeHandler, private val bookmarkHandler: BookmarkHandler, private val lectureSearchHandler: LectureSearchHandler, private val friendHandler: FriendHandler, @@ -174,6 +177,21 @@ class MainRouter( } } + @Bean + @ThemeDocs + fun timetableThemeRoute() = v1CoRouter { + "/themes".nest { + GET("", timetableThemeHandler::getThemes) + POST("", timetableThemeHandler::addTheme) + PATCH("{themeId}", timetableThemeHandler::modifyTheme) + DELETE("{themeId}", timetableThemeHandler::deleteTheme) + POST("{themeId}/copy", timetableThemeHandler::copyTheme) + POST("{themeId}/default", timetableThemeHandler::setDefault) + POST("basic/{basicThemeTypeValue}/default", timetableThemeHandler::setBasicThemeTypeDefault) + DELETE("{themeId}/default", timetableThemeHandler::unsetDefault) + } + } + private fun v1CoRouter(r: CoRouterFunctionDsl.() -> Unit) = coRouter { path("/v1").or("").nest(r) } diff --git a/api/src/main/kotlin/router/docs/ThemeDocs.kt b/api/src/main/kotlin/router/docs/ThemeDocs.kt new file mode 100644 index 00000000..cf93006b --- /dev/null +++ b/api/src/main/kotlin/router/docs/ThemeDocs.kt @@ -0,0 +1,91 @@ +package com.wafflestudio.snu4t.router.docs + +import com.wafflestudio.snu4t.timetables.dto.TimetableThemeDto +import com.wafflestudio.snu4t.timetables.dto.request.TimetableThemeAddRequestDto +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.parameters.RequestBody +import io.swagger.v3.oas.annotations.responses.ApiResponse +import org.springdoc.core.annotations.RouterOperation +import org.springdoc.core.annotations.RouterOperations +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.RequestMethod + +@RouterOperations( + RouterOperation( + path = "/v1/themes", method = [RequestMethod.GET], produces = [MediaType.APPLICATION_JSON_VALUE], + operation = Operation( + operationId = "getThemes", + parameters = [Parameter(`in` = ParameterIn.QUERY, name = "state", required = true)], + responses = [ApiResponse(responseCode = "200", content = [Content(array = ArraySchema(schema = Schema(implementation = TimetableThemeDto::class)))])], + ), + ), + RouterOperation( + path = "/v1/themes", method = [RequestMethod.POST], produces = [MediaType.APPLICATION_JSON_VALUE], + operation = Operation( + operationId = "addTheme", + requestBody = RequestBody( + content = [Content(schema = Schema(implementation = TimetableThemeAddRequestDto::class))], + required = true, + ), + responses = [ApiResponse(responseCode = "200", content = [Content(schema = Schema(implementation = TimetableThemeDto::class))])], + ), + ), + RouterOperation( + path = "/v1/themes/{themeId}", method = [RequestMethod.PATCH], produces = [MediaType.APPLICATION_JSON_VALUE], + operation = Operation( + operationId = "modifyTheme", + parameters = [Parameter(`in` = ParameterIn.PATH, name = "themeId", required = true)], + requestBody = RequestBody( + content = [Content(schema = Schema(implementation = TimetableThemeAddRequestDto::class))], + required = true, + ), + responses = [ApiResponse(responseCode = "200", content = [Content(schema = Schema(implementation = TimetableThemeDto::class))])], + ), + ), + RouterOperation( + path = "/v1/themes/{themeId}", method = [RequestMethod.DELETE], produces = [MediaType.APPLICATION_JSON_VALUE], + operation = Operation( + operationId = "deleteTheme", + parameters = [Parameter(`in` = ParameterIn.PATH, name = "themeId", required = true)], + responses = [ApiResponse(responseCode = "200", content = [Content(schema = Schema())])], + ), + ), + RouterOperation( + path = "/v1/themes/{themeId}/copy", method = [RequestMethod.POST], produces = [MediaType.APPLICATION_JSON_VALUE], + operation = Operation( + operationId = "copyTheme", + parameters = [Parameter(`in` = ParameterIn.PATH, name = "themeId", required = true)], + responses = [ApiResponse(responseCode = "200", content = [Content(schema = Schema(implementation = TimetableThemeDto::class))])], + ), + ), + RouterOperation( + path = "/v1/themes/{themeId}/default", method = [RequestMethod.POST], produces = [MediaType.APPLICATION_JSON_VALUE], + operation = Operation( + operationId = "setDefault", + parameters = [Parameter(`in` = ParameterIn.PATH, name = "themeId", required = true)], + responses = [ApiResponse(responseCode = "200", content = [Content(schema = Schema(implementation = TimetableThemeDto::class))])], + ), + ), + RouterOperation( + path = "/v1/themes/basic/{basicThemeTypeValue}/default", method = [RequestMethod.POST], produces = [MediaType.APPLICATION_JSON_VALUE], + operation = Operation( + operationId = "setBasicThemeTypeDefault", + parameters = [Parameter(`in` = ParameterIn.PATH, name = "basicThemeTypeValue", required = true)], + responses = [ApiResponse(responseCode = "200", content = [Content(schema = Schema(implementation = TimetableThemeDto::class))])], + ), + ), + RouterOperation( + path = "/v1/themes/{themeId}/copy", method = [RequestMethod.DELETE], produces = [MediaType.APPLICATION_JSON_VALUE], + operation = Operation( + operationId = "unsetDefault", + parameters = [Parameter(`in` = ParameterIn.PATH, name = "themeId", required = true)], + responses = [ApiResponse(responseCode = "200", content = [Content(schema = Schema(implementation = TimetableThemeDto::class))])], + ), + ), +) +annotation class ThemeDocs diff --git a/core/src/main/kotlin/common/enum/BasicThemeType.kt b/core/src/main/kotlin/common/enum/BasicThemeType.kt new file mode 100644 index 00000000..96f34247 --- /dev/null +++ b/core/src/main/kotlin/common/enum/BasicThemeType.kt @@ -0,0 +1,37 @@ +package com.wafflestudio.snu4t.common.enum + +import com.fasterxml.jackson.annotation.JsonValue +import org.springframework.core.convert.converter.Converter +import org.springframework.data.convert.ReadingConverter +import org.springframework.data.convert.WritingConverter +import org.springframework.stereotype.Component + +enum class BasicThemeType(@get:JsonValue val value: Int, val displayName: String) { + SNUTT(0, "SNUTT"), // 구 버전 호환을 위해 커스텀 테마의 경우 사용됨 + FALL(1, "가을"), + MODERN(2, "모던"), + CHERRY_BLOSSOM(3, "벚꽃"), + ICE(4, "얼음"), + LAWN(5, "잔디"), + ; + + companion object { + const val COLOR_COUNT = 9 + fun from(value: Int) = values().find { it.value == value } + fun from(displayName: String) = values().find { it.displayName == displayName } + } +} + +@ReadingConverter +@Component +class BasicThemeTypeReadConverter : Converter { + override fun convert(source: Int): BasicThemeType { + return requireNotNull(BasicThemeType.from(source)) + } +} + +@Component +@WritingConverter +class BasicThemeTypeWriteConverter : Converter { + override fun convert(source: BasicThemeType): Int = source.value +} diff --git a/core/src/main/kotlin/common/enum/TimetableTheme.kt b/core/src/main/kotlin/common/enum/TimetableTheme.kt deleted file mode 100644 index 90c04ea3..00000000 --- a/core/src/main/kotlin/common/enum/TimetableTheme.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.wafflestudio.snu4t.common.enum - -import com.fasterxml.jackson.annotation.JsonValue -import org.springframework.core.convert.converter.Converter -import org.springframework.data.convert.ReadingConverter -import org.springframework.data.convert.WritingConverter -import org.springframework.stereotype.Component - -enum class TimetableTheme(@get:JsonValue val value: Int) { - SNUTT(0), - FALL(1), - MODERN(2), - CHERRY_BLOSSOM(3), - ICE(4), - LAWN(5), - ; - - companion object { - private val valueMap = TimetableTheme.values().associateBy { e -> e.value } - fun from(value: Int) = valueMap[value] - } -} - -@ReadingConverter -@Component -class TimetableThemeReadConverter : Converter { - override fun convert(source: Int): TimetableTheme { - return requireNotNull(TimetableTheme.from(source)) - } -} - -@Component -@WritingConverter -class TimetableThemeWriteConverter : Converter { - override fun convert(source: TimetableTheme): Int = source.value -} diff --git a/core/src/main/kotlin/common/exception/ErrorType.kt b/core/src/main/kotlin/common/exception/ErrorType.kt index b4fd3de0..a9f53de5 100644 --- a/core/src/main/kotlin/common/exception/ErrorType.kt +++ b/core/src/main/kotlin/common/exception/ErrorType.kt @@ -43,6 +43,7 @@ enum class ErrorType( INVALID_DISPLAY_NAME(HttpStatus.BAD_REQUEST, 40009, "displayName이 유효하지 않습니다.", "조건에 맞지 않는 이름입니다."), TABLE_DELETE_ERROR(HttpStatus.BAD_REQUEST, 40010, "하나 남은 시간표는 삭제할 수 없습니다."), TIMETABLE_NOT_PRIMARY(HttpStatus.BAD_REQUEST, 40011, "대표 시간표가 아닙니다."), + INVALID_THEME_COLOR_COUNT(HttpStatus.BAD_REQUEST, 40012, "테마의 색상 개수가 적절하지 않습니다.", "테마의 색상 개수가 적절하지 않습니다."), TIMETABLE_NOT_FOUND(HttpStatus.NOT_FOUND, 40400, "timetable_id가 유효하지 않습니다", "존재하지 않는 시간표입니다."), PRIMARY_TIMETABLE_NOT_FOUND(HttpStatus.NOT_FOUND, 40401, "timetable_id가 유효하지 않습니다", "대표 시간표가 존재하지 않습니다."), @@ -50,11 +51,14 @@ enum class ErrorType( CONFIG_NOT_FOUND(HttpStatus.NOT_FOUND, 40403, "config가 존재하지 않습니다."), FRIEND_NOT_FOUND(HttpStatus.NOT_FOUND, 40404, "친구 관계가 존재하지 않습니다.", "친구 관계가 존재하지 않습니다."), USER_NOT_FOUND_BY_NICKNAME(HttpStatus.NOT_FOUND, 40405, "해당 닉네임의 유저를 찾을 수 없습니다.", "해당 닉네임의 유저를 찾을 수 없습니다."), + THEME_NOT_FOUND(HttpStatus.NOT_FOUND, 40406, "테마를 찾을 수 없습니다.", "테마를 찾을 수 없습니다."), DUPLICATE_VACANCY_NOTIFICATION(HttpStatus.CONFLICT, 40900, "빈자리 알림 중복"), DUPLICATE_EMAIL(HttpStatus.CONFLICT, 40901, "이미 사용 중인 이메일입니다."), DUPLICATE_FRIEND(HttpStatus.CONFLICT, 40902, "이미 친구 관계이거나 친구 요청을 보냈습니다.", "이미 친구 관계이거나 친구 요청을 보냈습니다."), INVALID_FRIEND(HttpStatus.CONFLICT, 40903, "친구 요청을 보낼 수 없는 유저입니다.", "친구 요청을 보낼 수 없는 유저입니다."), + DUPLICATE_THEME_NAME(HttpStatus.CONFLICT, 40904, "중복된 테마 이름입니다.", "중복된 테마 이름입니다."), + INVALID_THEME_TYPE(HttpStatus.CONFLICT, 40905, "적절하지 않은 유형의 테마입니다.", "적절하지 않은 유형의 테마입니다."), DYNAMIC_LINK_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 50001, "링크 생성 실패", "링크 생성에 실패했습니다. 잠시 후 다시 시도해주세요."), } diff --git a/core/src/main/kotlin/common/exception/Snu4tException.kt b/core/src/main/kotlin/common/exception/Snu4tException.kt index 8a2e5edb..ae00a2fb 100644 --- a/core/src/main/kotlin/common/exception/Snu4tException.kt +++ b/core/src/main/kotlin/common/exception/Snu4tException.kt @@ -42,6 +42,7 @@ object InvalidAppTypeException : Snu4tException(ErrorType.INVALID_APP_TYPE) 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 NoUserFcmKeyException : Snu4tException(ErrorType.NO_USER_FCM_KEY) object InvalidRegistrationForPreviousSemesterCourseException : @@ -62,10 +63,13 @@ object TimetableNotPrimaryException : Snu4tException(ErrorType.DEFAULT_ERROR) object ConfigNotFoundException : Snu4tException(ErrorType.CONFIG_NOT_FOUND) object FriendNotFoundException : Snu4tException(ErrorType.FRIEND_NOT_FOUND) object UserNotFoundByNicknameException : Snu4tException(ErrorType.USER_NOT_FOUND_BY_NICKNAME) +object ThemeNotFoundException : Snu4tException(ErrorType.THEME_NOT_FOUND) object DuplicateVacancyNotificationException : Snu4tException(ErrorType.DUPLICATE_VACANCY_NOTIFICATION) object DuplicateEmailException : Snu4tException(ErrorType.DUPLICATE_EMAIL) object DuplicateFriendException : Snu4tException(ErrorType.DUPLICATE_FRIEND) object InvalidFriendException : Snu4tException(ErrorType.INVALID_FRIEND) +object DuplicateThemeNameException : Snu4tException(ErrorType.DUPLICATE_THEME_NAME) +object InvalidThemeTypeException : Snu4tException(ErrorType.INVALID_THEME_TYPE) object DynamicLinkGenerationFailedException : Snu4tException(ErrorType.DYNAMIC_LINK_GENERATION_FAILED) diff --git a/core/src/main/kotlin/timetables/data/Timetable.kt b/core/src/main/kotlin/timetables/data/Timetable.kt index 1a8ad878..7a1473bd 100644 --- a/core/src/main/kotlin/timetables/data/Timetable.kt +++ b/core/src/main/kotlin/timetables/data/Timetable.kt @@ -1,8 +1,8 @@ package com.wafflestudio.snu4t.timetables.data import com.fasterxml.jackson.annotation.JsonProperty +import com.wafflestudio.snu4t.common.enum.BasicThemeType import com.wafflestudio.snu4t.common.enum.Semester -import com.wafflestudio.snu4t.common.enum.TimetableTheme import org.springframework.data.annotation.Id import org.springframework.data.mongodb.core.index.Indexed import org.springframework.data.mongodb.core.mapping.Document @@ -24,7 +24,8 @@ data class Timetable( @JsonProperty("lecture_list") var lectures: List = emptyList(), var title: String, - var theme: TimetableTheme, + var theme: BasicThemeType, + var themeId: String?, @Field("is_primary") var isPrimary: Boolean? = null, @Field("updated_at") diff --git a/core/src/main/kotlin/timetables/data/TimetableLecture.kt b/core/src/main/kotlin/timetables/data/TimetableLecture.kt index 5c59d6f6..e86d22de 100644 --- a/core/src/main/kotlin/timetables/data/TimetableLecture.kt +++ b/core/src/main/kotlin/timetables/data/TimetableLecture.kt @@ -45,7 +45,7 @@ data class TimetableLecture( var lectureId: String? = null, ) -fun TimetableLecture(lecture: Lecture, colorIndex: Int) = TimetableLecture( +fun TimetableLecture(lecture: Lecture, colorIndex: Int, color: ColorSet?) = TimetableLecture( lectureId = lecture.id, academicYear = lecture.academicYear, category = lecture.category, @@ -61,5 +61,5 @@ fun TimetableLecture(lecture: Lecture, colorIndex: Int) = TimetableLecture( courseNumber = lecture.courseNumber, courseTitle = lecture.courseTitle, colorIndex = colorIndex, - color = null, + color = color, ) diff --git a/core/src/main/kotlin/timetables/data/TimetableTheme.kt b/core/src/main/kotlin/timetables/data/TimetableTheme.kt new file mode 100644 index 00000000..3d5aa3e5 --- /dev/null +++ b/core/src/main/kotlin/timetables/data/TimetableTheme.kt @@ -0,0 +1,25 @@ +package com.wafflestudio.snu4t.timetables.data + +import org.springframework.data.annotation.Id +import org.springframework.data.mongodb.core.index.CompoundIndex +import org.springframework.data.mongodb.core.index.Indexed +import org.springframework.data.mongodb.core.mapping.Document +import org.springframework.data.mongodb.core.mapping.Field +import org.springframework.data.mongodb.core.mapping.FieldType +import java.time.LocalDateTime + +@Document +@CompoundIndex(def = "{ 'userId': 1, 'name': 1 }", unique = true) +data class TimetableTheme( + @Id + var id: String? = null, + @Indexed + @Field(targetType = FieldType.OBJECT_ID) + val userId: String, + var name: String, + var colors: List?, // basic 테마는 null (클라이언트 처리) + val isCustom: Boolean, // basic 테마는 false + var isDefault: Boolean, + val createdAt: LocalDateTime = LocalDateTime.now(), + var updatedAt: LocalDateTime = LocalDateTime.now(), +) diff --git a/core/src/main/kotlin/timetables/dto/TimetableDto.kt b/core/src/main/kotlin/timetables/dto/TimetableDto.kt index 801f45af..41813f87 100644 --- a/core/src/main/kotlin/timetables/dto/TimetableDto.kt +++ b/core/src/main/kotlin/timetables/dto/TimetableDto.kt @@ -1,8 +1,8 @@ package com.wafflestudio.snu4t.timetables.dto import com.fasterxml.jackson.annotation.JsonProperty +import com.wafflestudio.snu4t.common.enum.BasicThemeType import com.wafflestudio.snu4t.common.enum.Semester -import com.wafflestudio.snu4t.common.enum.TimetableTheme import com.wafflestudio.snu4t.timetables.data.Timetable import java.time.Instant @@ -13,7 +13,8 @@ data class TimetableDto( var semester: Semester, var lectures: List = emptyList(), var title: String, - val theme: TimetableTheme, + val theme: BasicThemeType, + val themeId: String?, val isPrimary: Boolean, var updatedAt: Instant = Instant.now(), ) @@ -26,6 +27,7 @@ fun TimetableDto(timetable: Timetable) = TimetableDto( lectures = timetable.lectures.map { TimetableLectureDto(it) }, title = timetable.title, theme = timetable.theme, + themeId = timetable.themeId, isPrimary = timetable.isPrimary ?: false, updatedAt = timetable.updatedAt, ) @@ -40,7 +42,8 @@ data class TimetableLegacyDto( @JsonProperty("lecture_list") var lectures: List = emptyList(), var title: String, - val theme: TimetableTheme, + val theme: BasicThemeType, + val themeId: String?, val isPrimary: Boolean, @JsonProperty("updated_at") var updatedAt: Instant = Instant.now(), @@ -54,6 +57,7 @@ fun TimetableLegacyDto(timetable: Timetable) = TimetableLegacyDto( lectures = timetable.lectures.map { TimetableLectureLegacyDto(it) }, title = timetable.title, theme = timetable.theme, + themeId = timetable.themeId, isPrimary = timetable.isPrimary ?: false, updatedAt = timetable.updatedAt, ) diff --git a/core/src/main/kotlin/timetables/dto/TimetableThemeDto.kt b/core/src/main/kotlin/timetables/dto/TimetableThemeDto.kt new file mode 100644 index 00000000..f03a2043 --- /dev/null +++ b/core/src/main/kotlin/timetables/dto/TimetableThemeDto.kt @@ -0,0 +1,27 @@ +package com.wafflestudio.snu4t.timetables.dto + +import com.wafflestudio.snu4t.common.enum.BasicThemeType +import com.wafflestudio.snu4t.timetables.data.ColorSet +import com.wafflestudio.snu4t.timetables.data.TimetableTheme +import com.wafflestudio.snu4t.timetables.service.toBasicThemeType + +data class TimetableThemeDto( + val id: String?, + val theme: BasicThemeType, + val name: String, + val colors: List?, + val isDefault: Boolean, + val isCustom: Boolean, +) + +fun TimetableThemeDto(timetableTheme: TimetableTheme) = + with(timetableTheme) { + TimetableThemeDto( + id = id, + theme = toBasicThemeType(), + name = name, + colors = colors, + isDefault = isDefault, + isCustom = isCustom, + ) + } diff --git a/core/src/main/kotlin/timetables/dto/request/TimetableModifyThemeRequestDto.kt b/core/src/main/kotlin/timetables/dto/request/TimetableModifyThemeRequestDto.kt index 3bf48e3c..ed6860f7 100644 --- a/core/src/main/kotlin/timetables/dto/request/TimetableModifyThemeRequestDto.kt +++ b/core/src/main/kotlin/timetables/dto/request/TimetableModifyThemeRequestDto.kt @@ -1,7 +1,8 @@ package com.wafflestudio.snu4t.timetables.dto.request -import com.wafflestudio.snu4t.common.enum.TimetableTheme +import com.wafflestudio.snu4t.common.enum.BasicThemeType data class TimetableModifyThemeRequestDto( - val theme: TimetableTheme, + val theme: BasicThemeType?, + val themeId: String?, ) diff --git a/core/src/main/kotlin/timetables/dto/request/TimetableThemeAddRequestDto.kt b/core/src/main/kotlin/timetables/dto/request/TimetableThemeAddRequestDto.kt new file mode 100644 index 00000000..d67bcb78 --- /dev/null +++ b/core/src/main/kotlin/timetables/dto/request/TimetableThemeAddRequestDto.kt @@ -0,0 +1,8 @@ +package com.wafflestudio.snu4t.timetables.dto.request + +import com.wafflestudio.snu4t.timetables.data.ColorSet + +data class TimetableThemeAddRequestDto( + val name: String, + val colors: List, +) diff --git a/core/src/main/kotlin/timetables/dto/request/TimetableThemeModifyRequestDto.kt b/core/src/main/kotlin/timetables/dto/request/TimetableThemeModifyRequestDto.kt new file mode 100644 index 00000000..35d6299b --- /dev/null +++ b/core/src/main/kotlin/timetables/dto/request/TimetableThemeModifyRequestDto.kt @@ -0,0 +1,8 @@ +package com.wafflestudio.snu4t.timetables.dto.request + +import com.wafflestudio.snu4t.timetables.data.ColorSet + +data class TimetableThemeModifyRequestDto( + val name: String?, + val colors: List?, +) diff --git a/core/src/main/kotlin/timetables/repository/TimetableThemeCustomRepository.kt b/core/src/main/kotlin/timetables/repository/TimetableThemeCustomRepository.kt new file mode 100644 index 00000000..6fecd2bb --- /dev/null +++ b/core/src/main/kotlin/timetables/repository/TimetableThemeCustomRepository.kt @@ -0,0 +1,28 @@ +package com.wafflestudio.snu4t.timetables.repository + +import com.wafflestudio.snu4t.common.extension.desc +import com.wafflestudio.snu4t.common.extension.isEqualTo +import com.wafflestudio.snu4t.common.extension.regex +import com.wafflestudio.snu4t.timetables.data.TimetableTheme +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.springframework.data.mongodb.core.ReactiveMongoTemplate +import org.springframework.data.mongodb.core.query.Query +import org.springframework.data.mongodb.core.query.and + +interface TimetableThemeCustomRepository { + suspend fun findLastChild(userId: String, name: String): TimetableTheme? +} + +class TimetableThemeCustomRepositoryImpl( + private val reactiveMongoTemplate: ReactiveMongoTemplate, +) : TimetableThemeCustomRepository { + override suspend fun findLastChild(userId: String, name: String): TimetableTheme? { + return reactiveMongoTemplate.findOne( + Query.query( + TimetableTheme::userId isEqualTo userId and + TimetableTheme::name regex """${Regex.escape(name)}(\s+\(\d+\))?""" + ).with(TimetableTheme::name.desc()), + TimetableTheme::class.java + ).awaitSingleOrNull() + } +} diff --git a/core/src/main/kotlin/timetables/repository/TimetableThemeRepository.kt b/core/src/main/kotlin/timetables/repository/TimetableThemeRepository.kt new file mode 100644 index 00000000..645749b7 --- /dev/null +++ b/core/src/main/kotlin/timetables/repository/TimetableThemeRepository.kt @@ -0,0 +1,14 @@ +package com.wafflestudio.snu4t.timetables.repository + +import com.wafflestudio.snu4t.timetables.data.TimetableTheme +import org.springframework.data.repository.kotlin.CoroutineCrudRepository + +interface TimetableThemeRepository : CoroutineCrudRepository, TimetableThemeCustomRepository { + suspend fun findByIdAndUserId(id: String, userId: String): TimetableTheme? + + suspend fun findByUserIdAndIsDefaultTrue(userId: String): TimetableTheme? + + suspend fun findByUserIdAndIsCustomTrueOrderByCreatedAtDesc(userId: String): List + + suspend fun existsByUserIdAndName(userId: String, name: String): Boolean +} diff --git a/core/src/main/kotlin/timetables/service/TimetableLectureService.kt b/core/src/main/kotlin/timetables/service/TimetableLectureService.kt index 629b0e43..c1b04342 100644 --- a/core/src/main/kotlin/timetables/service/TimetableLectureService.kt +++ b/core/src/main/kotlin/timetables/service/TimetableLectureService.kt @@ -45,6 +45,7 @@ interface TimetableLectureService { @Service class TimetableLectureServiceImpl( + private val timetableThemeService: TimetableThemeService, private val timetableRepository: TimetableRepository, private val lectureRepository: LectureRepository, ) : TimetableLectureService { @@ -52,9 +53,16 @@ class TimetableLectureServiceImpl( val timetable = timetableRepository.findByUserIdAndId(userId, timetableId) ?: throw TimetableNotFoundException val lecture = lectureRepository.findById(lectureId) ?: throw LectureNotFoundException if (!(timetable.year == lecture.year && timetable.semester == lecture.semester)) throw WrongSemesterException - val colorIndex = ColorUtils.getLeastUsedColorIndexByRandom(timetable.lectures.map { it.colorIndex }) + + 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() + } + if (timetable.lectures.any { it.lectureId == lectureId }) throw DuplicateTimetableLectureException - val timetableLecture = TimetableLecture(lecture, colorIndex) + val timetableLecture = TimetableLecture(lecture, colorIndex, color) resolveTimeConflict(timetable, timetableLecture, isForced) return timetableRepository.pushLecture(timetable.id!!, timetableLecture) } diff --git a/core/src/main/kotlin/timetables/service/TimetableService.kt b/core/src/main/kotlin/timetables/service/TimetableService.kt index cbea4621..eb5e51a2 100644 --- a/core/src/main/kotlin/timetables/service/TimetableService.kt +++ b/core/src/main/kotlin/timetables/service/TimetableService.kt @@ -2,8 +2,8 @@ package com.wafflestudio.snu4t.timetables.service import com.wafflestudio.snu4t.common.dynamiclink.client.DynamicLinkClient import com.wafflestudio.snu4t.common.dynamiclink.dto.DynamicLinkResponse +import com.wafflestudio.snu4t.common.enum.BasicThemeType import com.wafflestudio.snu4t.common.enum.Semester -import com.wafflestudio.snu4t.common.enum.TimetableTheme import com.wafflestudio.snu4t.common.exception.DuplicateTimetableTitleException import com.wafflestudio.snu4t.common.exception.InvalidTimetableTitleException import com.wafflestudio.snu4t.common.exception.PrimaryTimetableNotFoundException @@ -33,7 +33,7 @@ interface TimetableService { suspend fun getTimetable(userId: String, timetableId: String): Timetable suspend fun modifyTimetableTitle(userId: String, timetableId: String, title: String): Timetable suspend fun deleteTimetable(userId: String, timetableId: String) - suspend fun modifyTimetableTheme(userId: String, timetableId: String, theme: TimetableTheme): Timetable + suspend fun modifyTimetableTheme(userId: String, timetableId: String, basicThemeType: BasicThemeType?, themeId: String?): Timetable suspend fun copyTimetable(userId: String, timetableId: String, title: String? = null): Timetable suspend fun getUserPrimaryTable(userId: String, year: Int, semester: Semester): Timetable suspend fun getCoursebooksWithPrimaryTable(userId: String): List @@ -44,9 +44,10 @@ interface TimetableService { @Service class TimetableServiceImpl( + private val coursebookService: CoursebookService, + private val timetableThemeService: TimetableThemeService, private val timetableRepository: TimetableRepository, private val dynamicLinkClient: DynamicLinkClient, - private val coursebookService: CoursebookService, @Value("\${google.firebase.dynamic-link.link-prefix}") val linkPrefix: String, ) : TimetableService { override suspend fun getTimetables(userId: String): List = @@ -60,12 +61,15 @@ class TimetableServiceImpl( override suspend fun addTimetable(userId: String, timetableRequest: TimetableAddRequestDto): Timetable { validateTimetableTitle(userId, timetableRequest.year, timetableRequest.semester, timetableRequest.title) + + val defaultTheme = timetableThemeService.getDefaultTheme(userId) return Timetable( userId = userId, year = timetableRequest.year, semester = timetableRequest.semester, title = timetableRequest.title, - theme = TimetableTheme.SNUTT, + theme = defaultTheme.toBasicThemeType(), + themeId = defaultTheme?.id, isPrimary = timetableRepository .findAllByUserIdAndYearAndSemester(userId, timetableRequest.year, timetableRequest.semester) .toList() @@ -111,8 +115,27 @@ class TimetableServiceImpl( ).let { timetableRepository.save(it) } } - override suspend fun modifyTimetableTheme(userId: String, timetableId: String, theme: TimetableTheme): Timetable = - getTimetable(userId, timetableId).apply { this.theme = theme }.let { timetableRepository.save(it) } + override suspend fun modifyTimetableTheme(userId: String, timetableId: String, basicThemeType: BasicThemeType?, themeId: String?): Timetable { + require((themeId == null) xor (basicThemeType == null)) + + val timetable = getTimetable(userId, timetableId) + val theme = timetableThemeService.getTheme(userId, themeId, basicThemeType) + + timetable.theme = theme.toBasicThemeType() + timetable.themeId = theme.id + + val colorCount = if (theme.isCustom) requireNotNull(theme.colors).size else BasicThemeType.COLOR_COUNT + timetable.lectures.forEachIndexed { index, lecture -> + if (theme.isCustom) { + lecture.color = theme.colors!![index % colorCount] + lecture.colorIndex = 0 + } else { + lecture.color = null + lecture.colorIndex = (index % colorCount) + 1 + } + } + return timetableRepository.save(timetable) + } override suspend fun getUserPrimaryTable(userId: String, year: Int, semester: Semester): Timetable { return timetableRepository.findByUserIdAndYearAndSemester(userId, year, semester) @@ -130,12 +153,15 @@ class TimetableServiceImpl( override suspend fun createDefaultTable(userId: String) { val coursebook = coursebookService.getLatestCoursebook() + val defaultTheme = timetableThemeService.getDefaultTheme(userId) + val timetable = Timetable( userId = userId, year = coursebook.year, semester = coursebook.semester, title = "나의 시간표", - theme = TimetableTheme.SNUTT, + theme = defaultTheme.toBasicThemeType(), + themeId = defaultTheme?.id, ) timetableRepository.save(timetable) } diff --git a/core/src/main/kotlin/timetables/service/TimetableThemeService.kt b/core/src/main/kotlin/timetables/service/TimetableThemeService.kt new file mode 100644 index 00000000..968a5122 --- /dev/null +++ b/core/src/main/kotlin/timetables/service/TimetableThemeService.kt @@ -0,0 +1,174 @@ +package com.wafflestudio.snu4t.timetables.service + +import com.wafflestudio.snu4t.common.enum.BasicThemeType +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.ThemeNotFoundException +import com.wafflestudio.snu4t.timetables.data.ColorSet +import com.wafflestudio.snu4t.timetables.data.TimetableTheme +import com.wafflestudio.snu4t.timetables.repository.TimetableThemeRepository +import org.springframework.stereotype.Service +import java.time.LocalDateTime + +interface TimetableThemeService { + suspend fun getThemes(userId: String): List + + suspend fun addTheme(userId: String, name: String, colors: List): TimetableTheme + + suspend fun modifyTheme(userId: String, themeId: String, name: String?, colors: List?): TimetableTheme + + suspend fun deleteTheme(userId: String, themeId: String) + + suspend fun copyTheme(userId: String, themeId: String): TimetableTheme + + suspend fun setDefault(userId: String, themeId: String? = null, basicThemeType: BasicThemeType? = null): TimetableTheme + + suspend fun unsetDefault(userId: String, themeId: String): TimetableTheme + + suspend fun getDefaultTheme(userId: String): TimetableTheme? + + suspend fun getTheme(userId: String, themeId: String? = null, basicThemeType: BasicThemeType? = null): TimetableTheme +} + +@Service +class TimetableThemeServiceImpl( + private val timetableThemeRepository: TimetableThemeRepository, +) : TimetableThemeService { + companion object { + private const val MAX_COLOR_COUNT = 9 + private val copyNumberRegex = """\s\(\d+\)$""".toRegex() + } + + override suspend fun getThemes(userId: String): List { + val customThemes = timetableThemeRepository.findByUserIdAndIsCustomTrueOrderByCreatedAtDesc(userId) + val defaultTheme = getDefaultTheme(userId) + + return ( + BasicThemeType.values().map { buildTimetableTheme(userId, it, isDefault = it.displayName == defaultTheme?.name) } + + customThemes + ) + } + + override suspend fun addTheme(userId: String, name: String, colors: List): TimetableTheme { + if (colors.size !in 1..MAX_COLOR_COUNT) throw InvalidThemeColorCountException + if (timetableThemeRepository.existsByUserIdAndName(userId, name)) throw DuplicateThemeNameException + + val theme = TimetableTheme( + userId = userId, + name = name, + colors = colors, + isCustom = true, + isDefault = false, + ) + return timetableThemeRepository.save(theme) + } + + override suspend fun modifyTheme(userId: String, themeId: String, name: String?, colors: List?): TimetableTheme { + val theme = getCustomTheme(userId, themeId) + + name?.let { + if (theme.name != it && timetableThemeRepository.existsByUserIdAndName(userId, it)) throw DuplicateThemeNameException + theme.name = it + } + colors?.let { theme.colors = it } + theme.updatedAt = LocalDateTime.now() + return timetableThemeRepository.save(theme) + } + + override suspend fun deleteTheme(userId: String, themeId: String) { + val theme = getCustomTheme(userId, themeId) + timetableThemeRepository.delete(theme) + } + + override suspend fun copyTheme(userId: String, themeId: String): TimetableTheme { + val theme = getCustomTheme(userId, themeId) + + val baseName = theme.name.replace(copyNumberRegex, "") + val lastCopiedThemeNumber = getLastCopiedThemeNumber(userId, theme.name) + + val newTheme = TimetableTheme( + userId = userId, + name = "$baseName (${lastCopiedThemeNumber + 1})", + colors = theme.colors, + isCustom = true, + isDefault = false, + ) + return timetableThemeRepository.save(newTheme) + } + + private suspend fun getLastCopiedThemeNumber(userId: String, name: String): Int { + val baseName = name.replace(copyNumberRegex, "") + return timetableThemeRepository.findLastChild(userId, baseName)?.name + ?.replace(baseName, "")?.filter { it.isDigit() }?.toIntOrNull() ?: 0 + } + + override suspend fun setDefault(userId: String, themeId: String?, basicThemeType: BasicThemeType?): TimetableTheme { + require((themeId == null) xor (basicThemeType == null)) + + val theme = themeId?.let { + timetableThemeRepository.findByIdAndUserId(it, userId) ?: throw ThemeNotFoundException + } ?: buildTimetableTheme(userId, basicThemeType!!, isDefault = true) + + val defaultThemeBefore = timetableThemeRepository.findByUserIdAndIsDefaultTrue(userId) + defaultThemeBefore?.let { + if (it.isCustom) { + it.isDefault = false + it.updatedAt = LocalDateTime.now() + timetableThemeRepository.save(it) + } else { + timetableThemeRepository.delete(it) + } + } + + theme.isDefault = true + theme.updatedAt = LocalDateTime.now() + 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 + + if (theme.isCustom) { + theme.isDefault = false + theme.updatedAt = LocalDateTime.now() + timetableThemeRepository.save(theme) + } else { + timetableThemeRepository.delete(theme) + } + + return timetableThemeRepository.save(buildTimetableTheme(userId, BasicThemeType.SNUTT, isDefault = true)) + } + + override suspend fun getDefaultTheme(userId: String): TimetableTheme? { + return timetableThemeRepository.findByUserIdAndIsDefaultTrue(userId) + } + + override suspend fun getTheme(userId: String, themeId: String?, basicThemeType: BasicThemeType?): TimetableTheme { + require((themeId == null) xor (basicThemeType == null)) + + val defaultTheme = getDefaultTheme(userId) + + return themeId?.let { + timetableThemeRepository.findByIdAndUserId(it, userId) ?: throw ThemeNotFoundException + } ?: buildTimetableTheme(userId, basicThemeType!!, isDefault = basicThemeType.displayName == defaultTheme?.name) + } + + private suspend fun getCustomTheme(userId: String, themeId: String): TimetableTheme { + return timetableThemeRepository.findByIdAndUserId(themeId, userId)?.also { + if (!it.isCustom) throw InvalidThemeTypeException + } ?: throw ThemeNotFoundException + } + + private fun buildTimetableTheme(userId: String, basicThemeType: BasicThemeType, isDefault: Boolean) = + TimetableTheme( + userId = userId, + name = basicThemeType.displayName, + colors = null, + isCustom = false, + isDefault = isDefault, + ) +} + +fun TimetableTheme?.toBasicThemeType() = if (this == null || isCustom) BasicThemeType.SNUTT else requireNotNull(BasicThemeType.from(name)) diff --git a/core/src/testFixtures/kotlin/fixture/TimetableFixture.kt b/core/src/testFixtures/kotlin/fixture/TimetableFixture.kt index 95dc31be..46edd5f7 100644 --- a/core/src/testFixtures/kotlin/fixture/TimetableFixture.kt +++ b/core/src/testFixtures/kotlin/fixture/TimetableFixture.kt @@ -1,7 +1,7 @@ package com.wafflestudio.snu4t.fixture +import com.wafflestudio.snu4t.common.enum.BasicThemeType import com.wafflestudio.snu4t.common.enum.Semester -import com.wafflestudio.snu4t.common.enum.TimetableTheme import com.wafflestudio.snu4t.timetables.data.Timetable import org.springframework.stereotype.Component @@ -14,7 +14,8 @@ class TimetableFixture(val userFixture: UserFixture) { year = 2023, semester = Semester.AUTUMN, title = title, - theme = TimetableTheme.SNUTT, + theme = BasicThemeType.SNUTT, + themeId = null, ) } }