Skip to content

Commit 47cecbc

Browse files
authored
Merge pull request #375 from wafflestudio/develop
Release
2 parents f4e697b + 041a82e commit 47cecbc

14 files changed

Lines changed: 196 additions & 28 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ SNU Timetable API 서버
2828

2929
서울대학교 수강편람 검색 및 시간표 관리를 위한 애플리케이션입니다.
3030

31+
### 사용해보기
32+
- [](https://snutt.wafflestudio.com/)
33+
- [안드로이드 앱](https://play.google.com/store/apps/details?id=com.wafflestudio.snutt2.live)
34+
- [iOS 앱](https://apps.apple.com/us/app/snutt-%EC%84%9C%EC%9A%B8%EB%8C%80%ED%95%99%EA%B5%90-%EC%8B%9C%EA%B0%84%ED%91%9C-%EC%95%B1/id1215668309)
35+
3136
## Related Repositories
3237
### 서버 프로젝트
3338
- [snutt-ev](https://github.com/wafflestudio/snutt-ev) : 강의평 서비스 서버

api/src/main/kotlin/handler/TimetableThemeHandler.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ class TimetableThemeHandler(
3939
suspend fun getFriendsThemes(req: ServerRequest) =
4040
handle(req) {
4141
val userId = req.userId
42+
val page = req.parseRequiredQueryParam<Int>("page")
4243

43-
val themes = timetableThemeService.getFriendsThemes(userId)
44+
val themes = timetableThemeService.getFriendsThemes(userId, page)
4445
val result = timetableThemeService.convertThemesToTimetableDtos(themes)
4546
ListResponse(result)
4647
}
@@ -107,6 +108,14 @@ class TimetableThemeHandler(
107108
timetableThemeService.deleteTheme(userId, themeId)
108109
}
109110

111+
suspend fun deletePublishedTheme(req: ServerRequest) =
112+
handle(req) {
113+
val userId = req.userId
114+
val themeId = req.pathVariable("themeId")
115+
116+
timetableThemeService.deletePublishedTheme(userId, themeId)
117+
}
118+
110119
suspend fun copyTheme(req: ServerRequest) =
111120
handle(req) {
112121
val userId = req.userId

api/src/main/kotlin/router/MainRouter.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ class MainRouter(
279279
POST("/{themeId}/copy", timetableThemeHandler::copyTheme)
280280
POST("/{themeId}/default", timetableThemeHandler::setDefault)
281281
POST("/{themeId}/publish", timetableThemeHandler::publishTheme)
282+
DELETE("/{themeId}/publish", timetableThemeHandler::deletePublishedTheme)
282283
POST("/{themeId}/download", timetableThemeHandler::downloadTheme)
283284
POST("/search", timetableThemeHandler::searchThemes)
284285
POST("/basic/{basicThemeTypeValue}/default", timetableThemeHandler::setBasicThemeTypeDefault)

api/src/main/resources/views/member.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ <h3># 2025~</h3>
152152
이채민 / 자유전공학부 20 / iOS<br>
153153
이현도 / 컴퓨터공학부 23 / Android<br>
154154
임찬영 / 컴퓨터공학부 23 / 서버<br>
155-
정해찬 / 컴퓨터공학부 24 / Android<br>
155+
정해찬 / 산업공학과 24 / Android<br>
156156
조성해 / 건축학과 18 / 서버<br>
157157
최유림 / 컴퓨터공학부 19 / iOS<br>
158158
최유진 / 디자인과 22 / 디자인<br>

core/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ dependencies {
66
implementation("org.springframework.security:spring-security-crypto")
77
implementation("software.amazon.awssdk:s3:2.25.15")
88
implementation("software.amazon.awssdk:ses:2.25.15")
9-
implementation("com.wafflestudio.spring:spring-boot-starter-waffle:1.0.2")
9+
implementation("com.wafflestudio.spring:spring-boot-starter-waffle:1.0.4")
1010
implementation("com.google.firebase:firebase-admin:9.3.0")
1111
implementation("io.jsonwebtoken:jjwt:0.9.1")
1212

core/src/main/kotlin/common/exception/ErrorType.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ enum class ErrorType(
5454
ALREADY_LOCAL_ACCOUNT(HttpStatus.BAD_REQUEST, 40019, "이미 로컬 계정이 존재합니다.", "이미 로컬 계정이 존재합니다."),
5555
ALREADY_SOCIAL_ACCOUNT(HttpStatus.BAD_REQUEST, 40020, "이미 소셜 계정이 존재합니다.", "이미 소셜 계정이 존재합니다."),
5656
UPDATE_APP_VERSION(HttpStatus.BAD_REQUEST, 40021, "앱 버전을 업데이트해주세요.", "앱 버전을 업데이트해주세요."),
57+
NOT_PUBLISHED_THEME(HttpStatus.BAD_REQUEST, 40022, "공유한 테마가 아닙니다", "공유한 테마가 아닙니다"),
58+
PUBLISHED_THEME_DELETE_ERROR(HttpStatus.BAD_REQUEST, 40023, "테마마켓에서 테마를 내린 뒤 다시 시도해주세요", "테마마켓에서 테마를 내린 뒤 다시 시도해주세요."),
5759

5860
SOCIAL_CONNECT_FAIL(HttpStatus.UNAUTHORIZED, 40100, "소셜 로그인에 실패했습니다.", "소셜 로그인에 실패했습니다."),
5961
INVALID_APPLE_LOGIN_TOKEN(HttpStatus.UNAUTHORIZED, 40101, "유효하지 않은 애플 로그인 토큰입니다.", "소셜 로그인에 실패했습니다."),

core/src/main/kotlin/common/exception/SnuttException.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ object AlreadySocialAccountException : SnuttException(ErrorType.ALREADY_SOCIAL_A
8282

8383
object UpdateAppVersionException : SnuttException(ErrorType.UPDATE_APP_VERSION)
8484

85+
object NotPublishedThemeException : SnuttException(ErrorType.NOT_PUBLISHED_THEME)
86+
87+
object PublishedThemeDeleteErrorException : SnuttException(ErrorType.PUBLISHED_THEME_DELETE_ERROR)
88+
8589
object SocialConnectFailException : SnuttException(ErrorType.SOCIAL_CONNECT_FAIL)
8690

8791
object InvalidAppleLoginTokenException : SnuttException(ErrorType.INVALID_APPLE_LOGIN_TOKEN)

core/src/main/kotlin/notification/data/PushPreferenceType.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
package com.wafflestudio.snutt.notification.data
22

3-
enum class PushPreferenceType {
4-
NORMAL,
5-
LECTURE_UPDATE,
6-
VACANCY_NOTIFICATION,
3+
enum class PushPreferenceType(val isEnabledByDefault: Boolean) {
4+
NORMAL(true),
5+
LECTURE_UPDATE(true),
6+
VACANCY_NOTIFICATION(true),
77
}
88

99
fun PushPreferenceType(notificationType: NotificationType) =

core/src/main/kotlin/notification/service/PushPreferenceService.kt

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.wafflestudio.snutt.notification.service
22

33
import com.wafflestudio.snutt.notification.data.PushPreference
4+
import com.wafflestudio.snutt.notification.data.PushPreferenceItem
45
import com.wafflestudio.snutt.notification.data.PushPreferenceType
56
import com.wafflestudio.snutt.notification.dto.PushPreferenceDto
67
import com.wafflestudio.snutt.notification.repository.PushPreferenceRepository
@@ -34,12 +35,22 @@ class PushPreferenceServiceImpl(
3435
user: User,
3536
pushPreferenceDto: PushPreferenceDto,
3637
) {
38+
val pushPreferenceDtoMap = pushPreferenceDto.pushPreferences.associate { it.type to it.isEnabled }
39+
40+
val pushPreferenceItemsWithDefault =
41+
PushPreferenceType.entries.map {
42+
PushPreferenceItem(
43+
type = it,
44+
isEnabled = pushPreferenceDtoMap[it] ?: it.isEnabledByDefault,
45+
)
46+
}
47+
3748
pushPreferenceRepository.save(
3849
pushPreferenceRepository.findByUserId(user.id!!)
39-
?.copy(pushPreferences = pushPreferenceDto.pushPreferences)
50+
?.copy(pushPreferences = pushPreferenceItemsWithDefault)
4051
?: PushPreference(
4152
userId = user.id,
42-
pushPreferences = pushPreferenceDto.pushPreferences,
53+
pushPreferences = pushPreferenceItemsWithDefault,
4354
),
4455
)
4556
}
@@ -48,7 +59,12 @@ class PushPreferenceServiceImpl(
4859
pushPreferenceRepository.findByUserId(user.id!!)
4960
?.let { PushPreferenceDto(it) }
5061
?: PushPreferenceDto(
51-
pushPreferences = emptyList(),
62+
pushPreferences =
63+
PushPreferenceType.entries
64+
.filterNot { it == PushPreferenceType.NORMAL }
65+
.map {
66+
PushPreferenceItem(type = it, isEnabled = it.isEnabledByDefault)
67+
},
5268
)
5369

5470
override suspend fun isPushPreferenceEnabled(
@@ -62,8 +78,9 @@ class PushPreferenceServiceImpl(
6278
return pushPreferenceRepository
6379
.findByUserId(userId)
6480
?.pushPreferences
65-
?.any { it.type == pushPreferenceType && it.isEnabled }
66-
?: false
81+
?.find { it.type == pushPreferenceType }
82+
?.isEnabled
83+
?: pushPreferenceType.isEnabledByDefault
6784
}
6885

6986
override suspend fun filterUsersByPushPreference(
@@ -74,12 +91,20 @@ class PushPreferenceServiceImpl(
7491
return userIds
7592
}
7693

77-
return pushPreferenceRepository
78-
.findByUserIdIn(userIds)
79-
.filter { pushPreference ->
80-
pushPreference.pushPreferences
81-
.any { it.type == pushPreferenceType && it.isEnabled }
82-
}
83-
.map { it.userId }
94+
val userIdsToCustomPushPreferences =
95+
pushPreferenceRepository
96+
.findByUserIdIn(userIds)
97+
.associateBy { it.userId }
98+
99+
return userIds.filter {
100+
userId ->
101+
val customEnabled =
102+
userIdsToCustomPushPreferences[userId]
103+
?.pushPreferences
104+
?.find { it.type == pushPreferenceType }
105+
?.isEnabled
106+
107+
customEnabled ?: pushPreferenceType.isEnabledByDefault
108+
}
84109
}
85110
}

core/src/main/kotlin/theme/repository/TimetableThemeCustomRepository.kt

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,23 @@ import com.wafflestudio.snutt.theme.data.ThemeMarketInfo
77
import com.wafflestudio.snutt.theme.data.ThemeOrigin
88
import com.wafflestudio.snutt.theme.data.ThemeStatus
99
import com.wafflestudio.snutt.theme.data.TimetableTheme
10+
import com.wafflestudio.snutt.theme.dto.TimetableThemeDto
1011
import kotlinx.coroutines.reactive.awaitSingle
1112
import kotlinx.coroutines.reactor.awaitSingleOrNull
13+
import org.springframework.data.domain.Sort
1214
import org.springframework.data.mapping.div
1315
import org.springframework.data.mapping.toDotPath
1416
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
17+
import org.springframework.data.mongodb.core.aggregation.Aggregation
18+
import org.springframework.data.mongodb.core.aggregation.ArrayOperators
19+
import org.springframework.data.mongodb.core.aggregation.ConvertOperators
20+
import org.springframework.data.mongodb.core.aggregation.TypedAggregation
1521
import org.springframework.data.mongodb.core.exists
1622
import org.springframework.data.mongodb.core.find
1723
import org.springframework.data.mongodb.core.query.Query
1824
import org.springframework.data.mongodb.core.query.Update
1925
import org.springframework.data.mongodb.core.query.and
26+
import org.springframework.data.mongodb.core.query.inValues
2027
import org.springframework.data.mongodb.core.query.regex
2128
import org.springframework.data.mongodb.core.updateFirst
2229

@@ -36,11 +43,26 @@ interface TimetableThemeCustomRepository {
3643
): Boolean
3744

3845
suspend fun addDownloadCount(id: String)
46+
47+
suspend fun findOriginalThemesByUserIds(
48+
userIds: List<String>,
49+
page: Int,
50+
): List<TimetableTheme>
3951
}
4052

4153
class TimetableThemeCustomRepositoryImpl(
4254
private val reactiveMongoTemplate: ReactiveMongoTemplate,
4355
) : TimetableThemeCustomRepository {
56+
companion object {
57+
private const val EFFECTIVE_THEME_ID_ALIAS = "effectiveThemeId"
58+
private const val THEME_DETAILS_ALIAS = "themeDetails"
59+
private const val DOWNLOADED_THEMES_ALIAS = "downloadedThemes"
60+
private const val PUBLISHED_THEMES_ALIAS = "publishedThemes"
61+
private const val COMBINED_THEMES_ALIAS = "combinedThemes"
62+
private const val DOC_ALIAS = "doc"
63+
private const val ID_FIELD = "_id"
64+
}
65+
4466
override suspend fun findLastChild(
4567
userId: String,
4668
name: String,
@@ -88,4 +110,59 @@ class TimetableThemeCustomRepositoryImpl(
88110
Update().inc((TimetableTheme::publishInfo / ThemeMarketInfo::downloads).toDotPath(), 1),
89111
).awaitSingle()
90112
}
113+
114+
override suspend fun findOriginalThemesByUserIds(
115+
userIds: List<String>,
116+
page: Int,
117+
): List<TimetableTheme> {
118+
val downloadedOriginStages =
119+
arrayOf(
120+
Aggregation.match(
121+
TimetableThemeDto::userId.inValues(userIds)
122+
.and(TimetableThemeDto::status).isEqualTo(ThemeStatus.DOWNLOADED),
123+
),
124+
Aggregation.project()
125+
.and(ConvertOperators.ToObjectId.toObjectId("$${TimetableTheme::origin.div(ThemeOrigin::originId).toDotPath()}"))
126+
.`as`(EFFECTIVE_THEME_ID_ALIAS),
127+
Aggregation.lookup()
128+
.from(reactiveMongoTemplate.getCollectionName(TimetableTheme::class.java))
129+
.localField(EFFECTIVE_THEME_ID_ALIAS)
130+
.foreignField(ID_FIELD)
131+
.`as`(THEME_DETAILS_ALIAS),
132+
Aggregation.unwind(THEME_DETAILS_ALIAS),
133+
Aggregation.replaceRoot(THEME_DETAILS_ALIAS),
134+
)
135+
val publishedStages =
136+
arrayOf(
137+
Aggregation.match(
138+
TimetableTheme::userId.inValues(userIds)
139+
.and(TimetableTheme::status).isEqualTo(ThemeStatus.PUBLISHED),
140+
),
141+
)
142+
val facetOperation =
143+
Aggregation.facet()
144+
.and(*downloadedOriginStages).`as`(DOWNLOADED_THEMES_ALIAS)
145+
.and(*publishedStages).`as`(PUBLISHED_THEMES_ALIAS)
146+
val combinedAggregation =
147+
TypedAggregation.newAggregation(
148+
TimetableTheme::class.java,
149+
facetOperation,
150+
Aggregation.project()
151+
.and(ArrayOperators.ConcatArrays.arrayOf("$$DOWNLOADED_THEMES_ALIAS").concat("$$PUBLISHED_THEMES_ALIAS"))
152+
.`as`(COMBINED_THEMES_ALIAS),
153+
Aggregation.unwind("$$COMBINED_THEMES_ALIAS"),
154+
Aggregation.replaceRoot("$$COMBINED_THEMES_ALIAS"),
155+
Aggregation.group(ID_FIELD)
156+
.first(Aggregation.ROOT).`as`(DOC_ALIAS),
157+
Aggregation.replaceRoot("$$DOC_ALIAS"),
158+
Aggregation.sort(
159+
Sort.Direction.DESC,
160+
TimetableTheme::publishInfo.div(ThemeMarketInfo::downloads).toDotPath(),
161+
),
162+
)
163+
return reactiveMongoTemplate.aggregate(
164+
combinedAggregation,
165+
TimetableTheme::class.java,
166+
).skip((page - 1) * 10L).take(10).collectList().awaitSingle()
167+
}
91168
}

0 commit comments

Comments
 (0)