diff --git a/.github/workflows/ci-cd-main.yaml b/.github/workflows/ci-cd-main.yaml index 3a069f8d..84f07cc6 100644 --- a/.github/workflows/ci-cd-main.yaml +++ b/.github/workflows/ci-cd-main.yaml @@ -90,6 +90,7 @@ jobs: envs: APP, COMPOSE script_stop: true script: | + ssh api1 "aws ecr get-login-password --region ${{ env.AWS_REGION }} | docker login --username AWS --password-stdin ${{ secrets.AWS_ECR_REGISTRY }}" ssh api1 docker-compose -f $COMPOSE down ssh api1 docker pull ${{secrets.IMAGE_API}} ssh api1 docker-compose -p api -f $COMPOSE up -d @@ -178,6 +179,7 @@ jobs: envs: APP, COMPOSE script_stop: true script: | + ssh notification1 "aws ecr get-login-password --region ${{ env.AWS_REGION }} | docker login --username AWS --password-stdin ${{ secrets.AWS_ECR_REGISTRY }}" ssh notification1 docker-compose -f $COMPOSE down ssh notification1 docker pull ${{secrets.IMAGE_NOTIFICATION}} ssh notification1 docker-compose -p notification -f $COMPOSE up -d @@ -264,6 +266,7 @@ jobs: envs: APP, COMPOSE script_stop: true script: | + ssh batch1 "aws ecr get-login-password --region ${{ env.AWS_REGION }} | docker login --username AWS --password-stdin ${{ secrets.AWS_ECR_REGISTRY }}" ssh batch1 docker-compose -f $COMPOSE down ssh batch1 docker pull ${{secrets.IMAGE_BATCH}} ssh batch1 docker-compose -p batch -f $COMPOSE up -d @@ -350,6 +353,7 @@ jobs: envs: APP, COMPOSE script_stop: true script: | + ssh realtime1 "aws ecr get-login-password --region ${{ env.AWS_REGION }} | docker login --username AWS --password-stdin ${{ secrets.AWS_ECR_REGISTRY }}" ssh realtime1 docker-compose -f $COMPOSE down ssh realtime1 docker pull ${{secrets.IMAGE_REALTIME}} ssh realtime1 docker-compose -p realtime -f $COMPOSE up -d diff --git a/.gitignore b/.gitignore index fb10ec2a..393079d5 100644 --- a/.gitignore +++ b/.gitignore @@ -44,5 +44,5 @@ kafka_data .run -/*/src/*/resources/application-test.yaml +/*/src/*/resources/application-test*.yaml /notification/src/*/resources/firebase/*.json diff --git a/Dockerfile-api b/Dockerfile-api index a8e2f9f9..e7f1867f 100644 --- a/Dockerfile-api +++ b/Dockerfile-api @@ -2,4 +2,4 @@ FROM openjdk:17 RUN ln -snf /usr/share/zoneinfo/Asia/Seoul /etc/localtime && echo "Asia/Seoul" > /etc/timezone ARG JAR_FILE=api/build/libs/*.jar COPY ${JAR_FILE} /root/app.jar -CMD ["java", "-jar", "/root/app.jar"] +CMD ["java", "-Dspring.profiles.active=prod", "-jar", "/root/app.jar"] diff --git a/Dockerfile-batch b/Dockerfile-batch index 0f414bd5..1854a67d 100644 --- a/Dockerfile-batch +++ b/Dockerfile-batch @@ -2,4 +2,4 @@ FROM openjdk:17 RUN ln -snf /usr/share/zoneinfo/Asia/Seoul /etc/localtime && echo "Asia/Seoul" > /etc/timezone ARG JAR_FILE=batch/build/libs/*.jar COPY ${JAR_FILE} /root/app.jar -CMD ["java", "-jar", "/root/app.jar"] +CMD ["java", "-Dspring.profiles.active=prod", "-jar", "/root/app.jar"] diff --git a/Dockerfile-notification b/Dockerfile-notification index 171c8d17..a1e72244 100644 --- a/Dockerfile-notification +++ b/Dockerfile-notification @@ -2,4 +2,4 @@ FROM openjdk:17 RUN ln -snf /usr/share/zoneinfo/Asia/Seoul /etc/localtime && echo "Asia/Seoul" > /etc/timezone ARG JAR_FILE=notification/build/libs/*.jar COPY ${JAR_FILE} /root/app.jar -CMD ["java", "-jar", "/root/app.jar"] +CMD ["java", "-Dspring.profiles.active=prod", "-jar", "/root/app.jar"] diff --git a/Dockerfile-realtime b/Dockerfile-realtime index 592b998b..26baea82 100644 --- a/Dockerfile-realtime +++ b/Dockerfile-realtime @@ -2,5 +2,4 @@ FROM openjdk:17 RUN ln -snf /usr/share/zoneinfo/Asia/Seoul /etc/localtime && echo "Asia/Seoul" > /etc/timezone ARG JAR_FILE=realtime/build/libs/*.jar COPY ${JAR_FILE} /root/app.jar -ENV HOST_IP=host.docker.internal -CMD ["java", "-jar", "/root/app.jar"] +CMD ["java", "-Dspring.profiles.active=prod", "-jar", "/root/app.jar"] diff --git a/api/src/main/kotlin/com/backgu/amaker/api/chat/config/ChatServiceConfig.kt b/api/src/main/kotlin/com/backgu/amaker/api/chat/config/ChatServiceConfig.kt index d9f993ac..d7ce0a0d 100644 --- a/api/src/main/kotlin/com/backgu/amaker/api/chat/config/ChatServiceConfig.kt +++ b/api/src/main/kotlin/com/backgu/amaker/api/chat/config/ChatServiceConfig.kt @@ -10,10 +10,11 @@ import com.backgu.amaker.application.chat.service.ChatUserCacheFacadeService import com.backgu.amaker.application.event.service.EventAssignedUserService import com.backgu.amaker.application.user.service.UserCacheService import com.backgu.amaker.application.user.service.UserService -import com.backgu.amaker.infra.jpa.chat.repository.ChatRepository +import com.backgu.amaker.infra.jpa.chat.query.ChatRepository import com.backgu.amaker.infra.jpa.chat.repository.ChatRoomRepository import com.backgu.amaker.infra.jpa.chat.repository.ChatRoomUserRepository import com.backgu.amaker.infra.redis.chat.repository.ChatCacheRepository +import com.backgu.amaker.infra.redis.chat.repository.ChatPipelinedQueryRepository import com.backgu.amaker.infra.redis.chat.repository.ChatRoomUserCacheRepository import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -43,12 +44,14 @@ class ChatServiceConfig { fun chatUserCacheService( chatCacheService: ChatCacheService, userCacheService: UserCacheService, + chatPipelinedQueryRepository: ChatPipelinedQueryRepository, chatRoomUserCacheService: ChatRoomUserCacheService, userService: UserService, chatService: ChatService, eventAssignedUserService: EventAssignedUserService, ) = ChatUserCacheFacadeService( chatCacheService, + chatPipelinedQueryRepository, userCacheService, chatRoomUserCacheService, userService, diff --git a/api/src/main/kotlin/com/backgu/amaker/api/chat/dto/ChatWithUserDto.kt b/api/src/main/kotlin/com/backgu/amaker/api/chat/dto/ChatWithUserDto.kt index 2449be83..c89a9812 100644 --- a/api/src/main/kotlin/com/backgu/amaker/api/chat/dto/ChatWithUserDto.kt +++ b/api/src/main/kotlin/com/backgu/amaker/api/chat/dto/ChatWithUserDto.kt @@ -5,7 +5,7 @@ import com.backgu.amaker.api.user.dto.UserDto import com.backgu.amaker.domain.chat.ChatType import com.backgu.amaker.domain.chat.ChatWithUser import com.backgu.amaker.domain.chat.DefaultChatWithUser -import com.backgu.amaker.domain.event.EventWithUser +import com.backgu.amaker.domain.chat.EventChatWithUser import java.time.LocalDateTime sealed interface ChatWithUserDto { @@ -31,11 +31,11 @@ sealed interface ChatWithUserDto { user = UserDto.of(chatWithUser.user), ) - else -> + is EventChatWithUser -> EventChatWithUserDto( id = chatWithUser.id, chatRoomId = chatWithUser.chatRoomId, - content = EventWithUserDto.of(chatWithUser.content as EventWithUser), + content = EventWithUserDto.of(chatWithUser.content), chatType = chatWithUser.chatType, createdAt = chatWithUser.createdAt, updatedAt = chatWithUser.updatedAt, diff --git a/api/src/main/kotlin/com/backgu/amaker/api/chat/dto/response/ChatWithUserResponse.kt b/api/src/main/kotlin/com/backgu/amaker/api/chat/dto/response/ChatWithUserResponse.kt index be4db264..8b411c03 100644 --- a/api/src/main/kotlin/com/backgu/amaker/api/chat/dto/response/ChatWithUserResponse.kt +++ b/api/src/main/kotlin/com/backgu/amaker/api/chat/dto/response/ChatWithUserResponse.kt @@ -1,6 +1,7 @@ package com.backgu.amaker.api.chat.dto.response import com.backgu.amaker.api.chat.dto.ChatWithUserDto +import com.backgu.amaker.api.chat.dto.DefaultChatWithUserDto import com.backgu.amaker.api.chat.dto.EventChatWithUserDto import com.backgu.amaker.api.user.dto.response.UserResponse import com.backgu.amaker.domain.chat.ChatType @@ -32,7 +33,7 @@ interface ChatWithUserResponse { fun of(chatWithUserDto: ChatWithUserDto<*>): ChatWithUserResponse<*> = when (chatWithUserDto) { is EventChatWithUserDto -> EventChatWithUserResponse.of(chatWithUserDto) - else -> DefaultChatWithUserResponse.of(chatWithUserDto) + is DefaultChatWithUserDto -> DefaultChatWithUserResponse.of(chatWithUserDto) } } } diff --git a/api/src/main/kotlin/com/backgu/amaker/api/chat/service/ChatFacadeService.kt b/api/src/main/kotlin/com/backgu/amaker/api/chat/service/ChatFacadeService.kt index 5aa9631d..f08ac988 100644 --- a/api/src/main/kotlin/com/backgu/amaker/api/chat/service/ChatFacadeService.kt +++ b/api/src/main/kotlin/com/backgu/amaker/api/chat/service/ChatFacadeService.kt @@ -15,6 +15,7 @@ import com.backgu.amaker.application.chat.service.ChatService import com.backgu.amaker.application.chat.service.ChatUserCacheFacadeService import com.backgu.amaker.application.event.service.EventAssignedUserService import com.backgu.amaker.application.event.service.EventService +import com.backgu.amaker.application.notification.service.NotificationEventService import com.backgu.amaker.application.user.service.UserService import com.backgu.amaker.common.exception.BusinessException import com.backgu.amaker.common.status.StatusCode @@ -25,6 +26,7 @@ import com.backgu.amaker.domain.chat.ChatType import com.backgu.amaker.domain.chat.DefaultChatWithUser import com.backgu.amaker.domain.event.Event import com.backgu.amaker.domain.event.EventAssignedUser +import com.backgu.amaker.domain.notifiacation.chat.NewChat import com.backgu.amaker.domain.user.User import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service @@ -42,6 +44,7 @@ class ChatFacadeService( private val eventAssignedUserService: EventAssignedUserService, private val chatUserCacheFacadeService: ChatUserCacheFacadeService, private val eventPublisher: ApplicationEventPublisher, + private val notificationEventService: NotificationEventService, ) { @Transactional fun createChat( @@ -57,6 +60,7 @@ class ChatFacadeService( chatRoomService.save(chatRoom.updateLastChatId(chat)) eventPublisher.publishEvent(DefaultChatSaveEvent.of(chatRoomId, chat.createDefaultChatWithUser(user))) + notificationEventService.publishNotificationEvent(NewChat.of(user, chat, chatRoom)) return DefaultChatWithUserDto.of(chat, user) } @@ -66,8 +70,6 @@ class ChatFacadeService( userId: String, chatQuery: ChatQuery, ): ChatListDto { - markMostRecentChatAsRead(chatQuery.chatRoomId, userId) - val cachedChats = chatUserCacheFacadeService.findPreviousChats(chatQuery.chatRoomId, chatQuery.cursor, chatQuery.size) val dbQuerySize = chatQuery.size - cachedChats.size @@ -113,9 +115,6 @@ class ChatFacadeService( ?.let { return ChatListDto.of(chatQuery, it.map { chatWithUser -> ChatWithUserDto.of(chatWithUser) }) } val chatList = chatQueryService.findAfterChatList(chatQuery.chatRoomId, chatQuery.cursor, chatQuery.size) - for (defaultChatWithUser in chatList) { - println(defaultChatWithUser.id) - } val eventMap = eventService.findEventByIdsToMap(chatList.filter { ChatType.isEventChat(it.chatType) }.map { it.id }) @@ -142,19 +141,27 @@ class ChatFacadeService( chatUserCacheFacadeService.findChat(chatRoomId, it) } - if (cachedChat != null) return ChatWithUserDto.of(cachedChat) + if (cachedChat != null) { + markMostRecentChatAsRead(chatRoomId, userId) + return ChatWithUserDto.of(cachedChat) + } + + val chat = + chatQueryService.getOneWithUser( + chatRoomUser.lastReadChatId ?: chatRoomService.getById(chatRoomId).lastChatId, + ) - val chat = chatQueryService.getOneWithUser(chatRoomUser.lastReadChatId) + markMostRecentChatAsRead(chatRoomId, userId) - if (!ChatType.isEventChat(chat.chatType)) return DefaultChatWithUserDto.of(chat) + if (!ChatType.isEventChat(chat.chatType)) { + return DefaultChatWithUserDto.of(chat) + } val event = eventService.getEventById(chat.id) val eventAssignedUsers = eventAssignedUserService.findAllByEventId(event.id) val userMap = userService.findAllByUserIdsToMap(eventAssignedUsers.map { it.userId }) val eventUsers = eventAssignedUsers.mapNotNull { userMap[it.userId] } - markMostRecentChatAsRead(chatRoomId, userId) - return EventChatWithUserDto.of( chat, EventWithUserDto.of(event, eventUsers, eventAssignedUsers.count { it.isFinished }), diff --git a/api/src/main/kotlin/com/backgu/amaker/api/config/ClusterConfigProperties.kt b/api/src/main/kotlin/com/backgu/amaker/api/config/ClusterConfigProperties.kt new file mode 100644 index 00000000..66c32da2 --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/api/config/ClusterConfigProperties.kt @@ -0,0 +1,11 @@ +package com.backgu.amaker.api.config + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Configuration + +@Configuration +@ConfigurationProperties(prefix = "spring.data.redis.cluster") +class ClusterConfigProperties { + lateinit var nodes: List + var maxRedirects: Int = 3 +} diff --git a/api/src/main/kotlin/com/backgu/amaker/api/config/ProdRedisConfig.kt b/api/src/main/kotlin/com/backgu/amaker/api/config/ProdRedisConfig.kt new file mode 100644 index 00000000..be4b294c --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/api/config/ProdRedisConfig.kt @@ -0,0 +1,75 @@ +package com.backgu.amaker.api.config + +import io.lettuce.core.SocketOptions +import io.lettuce.core.cluster.ClusterClientOptions +import io.lettuce.core.cluster.ClusterTopologyRefreshOptions +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import org.springframework.data.redis.connection.RedisClusterConfiguration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.connection.RedisPassword +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories +import org.springframework.data.redis.serializer.StringRedisSerializer +import java.time.Duration + +@Configuration +@Profile("prod") +@EnableRedisRepositories +class ProdRedisConfig( + private val clusterConfigProperties: ClusterConfigProperties, +) { + @Value("\${spring.data.redis.password}") + lateinit var password: String + + @Bean + fun redisConnectionFactory(): RedisConnectionFactory { + val clusterConfiguration = RedisClusterConfiguration(clusterConfigProperties.nodes) + clusterConfiguration.maxRedirects = clusterConfigProperties.maxRedirects + clusterConfiguration.password = RedisPassword.of(password) + + val socketOptions = + SocketOptions + .builder() + .connectTimeout(Duration.ofMillis(100L)) + .keepAlive(true) + .build() + + val clusterTopologyRefreshOptions = + ClusterTopologyRefreshOptions + .builder() + .dynamicRefreshSources(true) + .enableAllAdaptiveRefreshTriggers() + .enablePeriodicRefresh(Duration.ofMinutes(30L)) + .build() + + val clientOptions = + ClusterClientOptions + .builder() + .topologyRefreshOptions(clusterTopologyRefreshOptions) + .socketOptions(socketOptions) + .build() + + val clientConfiguration = + LettuceClientConfiguration + .builder() + .clientOptions(clientOptions) + .commandTimeout(Duration.ofMillis(3000L)) + .build() + + return LettuceConnectionFactory(clusterConfiguration, clientConfiguration) + } + + @Bean + fun redisTemplate(): RedisTemplate { + val template = RedisTemplate() + template.connectionFactory = redisConnectionFactory() + template.keySerializer = StringRedisSerializer() + template.valueSerializer = StringRedisSerializer() + return template + } +} diff --git a/api/src/main/kotlin/com/backgu/amaker/api/config/ProdRedissonConfig.kt b/api/src/main/kotlin/com/backgu/amaker/api/config/ProdRedissonConfig.kt new file mode 100644 index 00000000..8ff6ae30 --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/api/config/ProdRedissonConfig.kt @@ -0,0 +1,41 @@ +package com.backgu.amaker.api.config + +import org.redisson.Redisson +import org.redisson.api.RedissonClient +import org.redisson.config.Config +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile + +@Configuration +@Profile("prod") +class ProdRedissonConfig( + private val clusterConfigProperties: ClusterConfigProperties, +) { + @Value("\${spring.data.redis.password}") + lateinit var password: String + + @Bean + fun redissonClient(): RedissonClient { + val config = Config() + val clusterServersConfig = + config + .useClusterServers() + .setScanInterval(2000) + .setConnectTimeout(100) + .setTimeout(3000) + .setRetryAttempts(3) + .setRetryInterval(1500) + + clusterConfigProperties.nodes.forEach { node -> + clusterServersConfig.addNodeAddress("redis://$node") + } + + if (password.isNotEmpty()) { + clusterServersConfig.setPassword(password) + } + + return Redisson.create(config) + } +} diff --git a/api/src/main/kotlin/com/backgu/amaker/api/config/RedisConfig.kt b/api/src/main/kotlin/com/backgu/amaker/api/config/RedisConfig.kt index 94da437c..eca18003 100644 --- a/api/src/main/kotlin/com/backgu/amaker/api/config/RedisConfig.kt +++ b/api/src/main/kotlin/com/backgu/amaker/api/config/RedisConfig.kt @@ -3,11 +3,13 @@ package com.backgu.amaker.api.config import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile import org.springframework.data.redis.connection.RedisConnectionFactory import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory import org.springframework.data.redis.core.RedisTemplate import org.springframework.data.redis.serializer.StringRedisSerializer +@Profile("!prod") @Configuration @ConfigurationProperties(prefix = "spring.data.redis") class RedisConfig { diff --git a/api/src/main/kotlin/com/backgu/amaker/api/config/RedissonConfig.kt b/api/src/main/kotlin/com/backgu/amaker/api/config/RedissonConfig.kt index 8907e3f2..dcd920f2 100644 --- a/api/src/main/kotlin/com/backgu/amaker/api/config/RedissonConfig.kt +++ b/api/src/main/kotlin/com/backgu/amaker/api/config/RedissonConfig.kt @@ -5,7 +5,9 @@ import org.redisson.api.RedissonClient import org.redisson.config.Config import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +@Profile("!prod") @Configuration class RedissonConfig( private val redisConfig: RedisConfig, diff --git a/api/src/main/kotlin/com/backgu/amaker/api/workspace/service/WorkspaceFacadeService.kt b/api/src/main/kotlin/com/backgu/amaker/api/workspace/service/WorkspaceFacadeService.kt index 365e7713..a7ab81a0 100644 --- a/api/src/main/kotlin/com/backgu/amaker/api/workspace/service/WorkspaceFacadeService.kt +++ b/api/src/main/kotlin/com/backgu/amaker/api/workspace/service/WorkspaceFacadeService.kt @@ -52,6 +52,7 @@ class WorkspaceFacadeService( } val chatRoom: ChatRoom = chatRoomService.save(workspace.createDefaultChatRoom()) + workspaceService.save(workspace) chatRoomUserService.save(chatRoom.addUser(leader)) return WorkspaceDto.of(workspace) @@ -138,7 +139,6 @@ class WorkspaceFacadeService( val workspaceUser = workspaceUserService.getWorkspaceUser(workspace, user) if (workspaceUser.isActivated()) throw BusinessException(StatusCode.ALREADY_JOINED_WORKSPACE) - // TODO 트랜잭션 종료시점에 이벤트 publish notificationEventService.publishNotificationEvent(WorkspaceJoined.of(workspace, user)) workspaceUserService.save(workspaceUser.activate()) @@ -161,7 +161,6 @@ class WorkspaceFacadeService( val workspaceUser = workspaceUserService.getWorkspaceUser(workspace, user) if (workspaceUser.isActivated()) throw BusinessException(StatusCode.ALREADY_JOINED_WORKSPACE) - // TODO 트랜잭션 종료시점에 이벤트 publish notificationEventService.publishNotificationEvent(WorkspaceJoined.of(workspace, user)) workspaceUserService.save(workspaceUser.activate()) @@ -185,7 +184,6 @@ class WorkspaceFacadeService( val workspaceUser = workspaceUserService.getWorkspaceUser(workspace, user) if (workspaceUser.isActivated()) throw BusinessException(StatusCode.ALREADY_JOINED_WORKSPACE) - // TODO 트랜잭션 종료시점에 이벤트 publish notificationEventService.publishNotificationEvent(WorkspaceJoined.of(workspace, user)) workspaceUserService.save(workspaceUser.activate()) diff --git a/api/src/test/kotlin/com/backgu/amaker/api/chat/service/ChatFacadeServiceTest.kt b/api/src/test/kotlin/com/backgu/amaker/api/chat/service/ChatFacadeServiceTest.kt index 755f8289..26301af5 100644 --- a/api/src/test/kotlin/com/backgu/amaker/api/chat/service/ChatFacadeServiceTest.kt +++ b/api/src/test/kotlin/com/backgu/amaker/api/chat/service/ChatFacadeServiceTest.kt @@ -173,7 +173,7 @@ class ChatFacadeServiceTest : IntegrationTest() { // given val userId = "test-user-id" val chatRoom: ChatRoom = fixture.setUp(userId = userId) - val prevChats: List = fixture.chatFixture.createPersistedChats(chatRoom.id, userId, 30) + fixture.chatFixture.createPersistedChats(chatRoom.id, userId, 30) val currentChat: Chat = fixture.chatFixture.createPersistedChat(chatRoom.id, userId, "현재 테스트 메시지") // when diff --git a/api/src/test/kotlin/com/backgu/amaker/api/mail/infra/FakeEmailSender.kt b/api/src/test/kotlin/com/backgu/amaker/api/mail/infra/FakeEmailSender.kt deleted file mode 100644 index df103a2c..00000000 --- a/api/src/test/kotlin/com/backgu/amaker/api/mail/infra/FakeEmailSender.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.backgu.amaker.api.mail.infra - -import com.backgu.amaker.api.common.annotation.IntegrationTestComponent -import com.backgu.amaker.application.notification.mail.service.EmailSender -import io.github.oshai.kotlinlogging.KotlinLogging - -private val logger = KotlinLogging.logger {} - -@IntegrationTestComponent -class FakeEmailSender : EmailSender { - override fun sendEmail( - emailAddress: String, - title: String, - body: String, - ) { - logger.info { "Test Sending email to $emailAddress with title $title and body $body" } - } -} diff --git a/domain/src/main/kotlin/com/backgu/amaker/domain/chat/ChatWithUser.kt b/domain/src/main/kotlin/com/backgu/amaker/domain/chat/ChatWithUser.kt index ec4d7558..fc30976e 100644 --- a/domain/src/main/kotlin/com/backgu/amaker/domain/chat/ChatWithUser.kt +++ b/domain/src/main/kotlin/com/backgu/amaker/domain/chat/ChatWithUser.kt @@ -3,7 +3,7 @@ package com.backgu.amaker.domain.chat import com.backgu.amaker.domain.user.User import java.time.LocalDateTime -interface ChatWithUser { +sealed interface ChatWithUser { val id: Long val chatRoomId: Long val content: T diff --git a/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/ChatRoomNotification.kt b/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/ChatRoomNotification.kt new file mode 100644 index 00000000..a1c7c4f1 --- /dev/null +++ b/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/ChatRoomNotification.kt @@ -0,0 +1,14 @@ +package com.backgu.amaker.domain.notifiacation + +import com.backgu.amaker.domain.notifiacation.method.RealTimeNotificationMethod + +open class ChatRoomNotification( + val chatRoomId: Long, + override val method: RealTimeNotificationMethod, +) : RealTimeBasedNotification(method) { + override val keyPrefix: String + get() = "CHAT_ROOM" + + override val keyValue: String + get() = chatRoomId.toString() +} diff --git a/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/Notification.kt b/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/Notification.kt index d1903802..226c064e 100644 --- a/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/Notification.kt +++ b/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/Notification.kt @@ -5,4 +5,10 @@ import java.io.Serializable interface Notification : Serializable { val method: NotificationMethod + val keyPrefix: String + val keyValue: String + + fun getNotificationKey(): String { + return "$keyPrefix:$keyValue" + } } diff --git a/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/PushNotification.kt b/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/PushNotification.kt index 6257a528..2dfc3db6 100644 --- a/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/PushNotification.kt +++ b/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/PushNotification.kt @@ -6,4 +6,10 @@ import com.backgu.amaker.domain.user.UserDevice class PushNotification( override val method: NotificationMethod, val userDevices: List, -) : Notification +) : Notification { + override val keyPrefix: String + get() = "PUSH" + + override val keyValue: String + get() = userDevices.first().userId +} diff --git a/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/UserFulfilledNotification.kt b/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/UserFulfilledNotification.kt index 6c22e169..7cd570a0 100644 --- a/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/UserFulfilledNotification.kt +++ b/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/UserFulfilledNotification.kt @@ -6,4 +6,10 @@ import com.backgu.amaker.domain.user.User class UserFulfilledNotification( val user: User, override val method: RealTimeNotificationMethod, -) : RealTimeBasedNotification(method) +) : RealTimeBasedNotification(method) { + override val keyPrefix: String + get() = "USER" + + override val keyValue: String + get() = user.id +} diff --git a/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/UserNotification.kt b/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/UserNotification.kt index 9a5ec2d2..75c4f8f9 100644 --- a/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/UserNotification.kt +++ b/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/UserNotification.kt @@ -5,4 +5,10 @@ import com.backgu.amaker.domain.notifiacation.method.RealTimeNotificationMethod open class UserNotification( val userId: String, override val method: RealTimeNotificationMethod, -) : RealTimeBasedNotification(method) +) : RealTimeBasedNotification(method) { + override val keyPrefix: String + get() = "USER" + + override val keyValue: String + get() = userId +} diff --git a/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/WorkspaceNotification.kt b/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/WorkspaceNotification.kt index 9cea6198..fe835a82 100644 --- a/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/WorkspaceNotification.kt +++ b/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/WorkspaceNotification.kt @@ -5,4 +5,10 @@ import com.backgu.amaker.domain.notifiacation.method.RealTimeNotificationMethod open class WorkspaceNotification( open val workspaceId: Long, override val method: RealTimeNotificationMethod, -) : RealTimeBasedNotification(method) +) : RealTimeBasedNotification(method) { + override val keyPrefix: String + get() = "WORKSPACE" + + override val keyValue: String + get() = workspaceId.toString() +} diff --git a/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/chat/NewChat.kt b/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/chat/NewChat.kt new file mode 100644 index 00000000..17b84c96 --- /dev/null +++ b/domain/src/main/kotlin/com/backgu/amaker/domain/notifiacation/chat/NewChat.kt @@ -0,0 +1,39 @@ +package com.backgu.amaker.domain.notifiacation.chat + +import com.backgu.amaker.domain.chat.Chat +import com.backgu.amaker.domain.chat.ChatRoom +import com.backgu.amaker.domain.notifiacation.ChatRoomNotification +import com.backgu.amaker.domain.notifiacation.method.TemplateEmailNotificationMethod +import com.backgu.amaker.domain.user.User + +class NewChat( + chatRoom: ChatRoom, + method: TemplateEmailNotificationMethod, +) : ChatRoomNotification( + chatRoom.id, + method, + ) { + companion object { + private fun buildDetailMessage( + chatRoom: ChatRoom, + chat: Chat, + ): String = + "${chatRoom.name}에 새로운 메시지가 도착했습니다.\n" + + "메시지 내용: ${chat.content}" + + fun of( + sender: User, + chat: Chat, + chatRoom: ChatRoom, + ): NewChat = + NewChat( + chatRoom, + TemplateEmailNotificationMethod( + "${sender.name}님이 보낸 메시지", + chat.content, + buildDetailMessage(chatRoom, chat), + "chat-notification", + ), + ) + } +} diff --git a/domain/src/main/kotlin/com/backgu/amaker/domain/workspace/Workspace.kt b/domain/src/main/kotlin/com/backgu/amaker/domain/workspace/Workspace.kt index 1ac5d2bb..8fb4de53 100644 --- a/domain/src/main/kotlin/com/backgu/amaker/domain/workspace/Workspace.kt +++ b/domain/src/main/kotlin/com/backgu/amaker/domain/workspace/Workspace.kt @@ -35,7 +35,6 @@ class Workspace( fun createDefaultChatRoom(): ChatRoom = ChatRoom(workspaceId = id, name = "일반 채팅", chatRoomType = ChatRoomType.DEFAULT) - override fun toString(): String { - return "Workspace(id=$id, name='$name', thumbnail='$thumbnail', belongingNumber=$belongingNumber, workspacePlan=$workspacePlan)" - } + override fun toString(): String = + "Workspace(id=$id, name='$name', thumbnail='$thumbnail', belongingNumber=$belongingNumber, workspacePlan=$workspacePlan)" } diff --git a/infra/build.gradle.kts b/infra/build.gradle.kts index ce805da3..d63ddd3d 100644 --- a/infra/build.gradle.kts +++ b/infra/build.gradle.kts @@ -22,14 +22,11 @@ dependencies { implementation(project(":domain")) implementation(project(":common")) implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.2") - implementation("org.springframework.boot:spring-boot-starter-mail") - implementation("org.springframework.boot:spring-boot-starter-thymeleaf") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("com.mysql:mysql-connector-j:8.4.0") implementation("org.springframework.kafka:spring-kafka:3.1.4") implementation("org.springframework.boot:spring-boot-starter-data-redis") implementation("org.springframework.boot:spring-boot-starter-web") -// implementation("com.fasterxml.jackson.core:jackson-databind:2.13.3") implementation("org.reflections:reflections:0.10.2") implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta") diff --git a/infra/src/main/kotlin/com/backgu/amaker/application/chat/service/ChatQueryService.kt b/infra/src/main/kotlin/com/backgu/amaker/application/chat/service/ChatQueryService.kt index 686998e3..291c4cf6 100644 --- a/infra/src/main/kotlin/com/backgu/amaker/application/chat/service/ChatQueryService.kt +++ b/infra/src/main/kotlin/com/backgu/amaker/application/chat/service/ChatQueryService.kt @@ -3,7 +3,7 @@ package com.backgu.amaker.application.chat.service import com.backgu.amaker.common.exception.BusinessException import com.backgu.amaker.common.status.StatusCode import com.backgu.amaker.domain.chat.DefaultChatWithUser -import com.backgu.amaker.infra.jpa.chat.repository.ChatRepository +import com.backgu.amaker.infra.jpa.chat.query.ChatRepository import org.springframework.stereotype.Service @Service diff --git a/infra/src/main/kotlin/com/backgu/amaker/application/chat/service/ChatRoomUserService.kt b/infra/src/main/kotlin/com/backgu/amaker/application/chat/service/ChatRoomUserService.kt index 9b1d54aa..c22b157e 100644 --- a/infra/src/main/kotlin/com/backgu/amaker/application/chat/service/ChatRoomUserService.kt +++ b/infra/src/main/kotlin/com/backgu/amaker/application/chat/service/ChatRoomUserService.kt @@ -67,4 +67,6 @@ class ChatRoomUserService( fun findAllByChatRoom(chatRoom: ChatRoom): List = chatRoomUserRepository.findAllByChatRoomId(chatRoom.id).map { it.toDomain() } + + fun findUserIdsByChatRoomId(chatRoomId: Long): List = chatRoomUserRepository.findUserIdsByChatRoomId(chatRoomId) } diff --git a/infra/src/main/kotlin/com/backgu/amaker/application/chat/service/ChatService.kt b/infra/src/main/kotlin/com/backgu/amaker/application/chat/service/ChatService.kt index 3d03a552..879087e9 100644 --- a/infra/src/main/kotlin/com/backgu/amaker/application/chat/service/ChatService.kt +++ b/infra/src/main/kotlin/com/backgu/amaker/application/chat/service/ChatService.kt @@ -4,7 +4,7 @@ import com.backgu.amaker.common.exception.BusinessException import com.backgu.amaker.common.status.StatusCode import com.backgu.amaker.domain.chat.Chat import com.backgu.amaker.infra.jpa.chat.entity.ChatEntity -import com.backgu.amaker.infra.jpa.chat.repository.ChatRepository +import com.backgu.amaker.infra.jpa.chat.query.ChatRepository import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional diff --git a/infra/src/main/kotlin/com/backgu/amaker/application/chat/service/ChatUserCacheFacadeService.kt b/infra/src/main/kotlin/com/backgu/amaker/application/chat/service/ChatUserCacheFacadeService.kt index 63f7b942..ceca9629 100644 --- a/infra/src/main/kotlin/com/backgu/amaker/application/chat/service/ChatUserCacheFacadeService.kt +++ b/infra/src/main/kotlin/com/backgu/amaker/application/chat/service/ChatUserCacheFacadeService.kt @@ -11,12 +11,14 @@ import com.backgu.amaker.domain.user.User import com.backgu.amaker.infra.redis.chat.data.ChatWithUserCache import com.backgu.amaker.infra.redis.chat.data.DefaultChatWithUserCache import com.backgu.amaker.infra.redis.chat.data.EventChatWithUserCache +import com.backgu.amaker.infra.redis.chat.repository.ChatPipelinedQueryRepository import org.springframework.stereotype.Service import org.springframework.transaction.event.TransactionalEventListener @Service class ChatUserCacheFacadeService( private val chatCacheService: ChatCacheService, + private val chatPipelinedQueryRepository: ChatPipelinedQueryRepository, private val userCacheService: UserCacheService, private val chatRoomUserCacheService: ChatRoomUserCacheService, private val userService: UserService, @@ -56,8 +58,8 @@ class ChatUserCacheFacadeService( userCacheService.save(fetchedUser) } - when (chat) { - is DefaultChatWithUserCache -> return chat.toDomain(user) + return when (chat) { + is DefaultChatWithUserCache -> chat.toDomain(user) is EventChatWithUserCache -> { val userIds = chat.content.users val cachedUsers = userCacheService.findAllByUserIds(userIds) @@ -66,7 +68,7 @@ class ChatUserCacheFacadeService( userService.getAllByUserIds(missingUserIds).onEach { userCacheService.save(it) } val allUsers = cachedUsers + fetchedUsers - return chat.toDomain(user, chat.content.toDomain(allUsers)) + chat.toDomain(user, chat.content.toDomain(allUsers)) } } } @@ -83,6 +85,7 @@ class ChatUserCacheFacadeService( return chatCacheService .findPreviousChats(chatRoomId, cursor, count) + .reversed() .map { chat -> mapChatToDto(chatRoomId, chat, cachedUsersMap) } } @@ -103,7 +106,21 @@ class ChatUserCacheFacadeService( .map { chat -> mapChatToDto(chatRoomId, chat, cachedUsersMap) } } - fun mapChatToDto( + fun findAfterChatsWithPipelinedQuery( + chatRoomId: Long, + cursor: Long, + count: Int, + ): List>? = + chatPipelinedQueryRepository.findAfterChats(chatRoomId, cursor, count)?.let { chats -> + userCacheService + .findAllByUserIds(chatRoomUserCacheService.findUserIds(chatRoomId).toList()) + .associateBy { it.id } + .let { cachedUsersMap -> + chats.map { chat -> mapChatToDto(chatRoomId, chat, cachedUsersMap) } + } + } + + private fun mapChatToDto( chatRoomId: Long, chat: ChatWithUserCache<*>, cachedUsersMap: Map, @@ -134,10 +151,6 @@ class ChatUserCacheFacadeService( return chat.toDomain(user, chat.content.toDomain(allUsers)) } - - else -> { - throw IllegalArgumentException("Invalid chat type") - } } } } diff --git a/infra/src/main/kotlin/com/backgu/amaker/application/user/service/UserService.kt b/infra/src/main/kotlin/com/backgu/amaker/application/user/service/UserService.kt index f4ffe46b..9da5bf0f 100644 --- a/infra/src/main/kotlin/com/backgu/amaker/application/user/service/UserService.kt +++ b/infra/src/main/kotlin/com/backgu/amaker/application/user/service/UserService.kt @@ -59,4 +59,10 @@ class UserService( if (users.isEmpty()) throw BusinessException(StatusCode.USER_NOT_FOUND) return users } + + fun getByChatRoomId(chatRoomId: Long): List { + val users = userRepository.findByChatRoomId(chatRoomId).map { it.toDomain() } + if (users.isEmpty()) throw BusinessException(StatusCode.USER_NOT_FOUND) + return users + } } diff --git a/infra/src/main/kotlin/com/backgu/amaker/application/workspace/WorkspaceService.kt b/infra/src/main/kotlin/com/backgu/amaker/application/workspace/WorkspaceService.kt index 444cf6fd..7b53df78 100644 --- a/infra/src/main/kotlin/com/backgu/amaker/application/workspace/WorkspaceService.kt +++ b/infra/src/main/kotlin/com/backgu/amaker/application/workspace/WorkspaceService.kt @@ -23,7 +23,10 @@ class WorkspaceService( @Transactional fun updateBelonging(workspace: Workspace) { - workspaceRepository.updateBelongingWithLimit(workspace.id, workspace.workspacePlan.belongingLimit) != 1 && + workspaceRepository.updateBelongingWithLimit( + workspace.id, + workspace.workspacePlan.belongingLimit, + ) != 1 && throw BusinessException(StatusCode.INVALID_WORKSPACE_JOIN) } @@ -47,4 +50,8 @@ class WorkspaceService( fun getWorkspaceById(workspaceId: Long): Workspace = workspaceRepository.findByIdOrNull(workspaceId)?.toDomain() ?: throw BusinessException(StatusCode.WORKSPACE_NOT_FOUND) + + fun getWorkspaceIdByChatRoomId(chatRoomId: Long): Long = + workspaceRepository.getWorkspaceIdByChatRoomId(chatRoomId) + ?: throw BusinessException(StatusCode.WORKSPACE_NOT_FOUND) } diff --git a/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/chat/repository/ChatQueryRepository.kt b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/chat/query/ChatQueryRepository.kt similarity index 78% rename from infra/src/main/kotlin/com/backgu/amaker/infra/jpa/chat/repository/ChatQueryRepository.kt rename to infra/src/main/kotlin/com/backgu/amaker/infra/jpa/chat/query/ChatQueryRepository.kt index 940387a4..84acfa74 100644 --- a/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/chat/repository/ChatQueryRepository.kt +++ b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/chat/query/ChatQueryRepository.kt @@ -1,6 +1,4 @@ -package com.backgu.amaker.infra.jpa.chat.repository - -import com.backgu.amaker.infra.jpa.chat.query.ChatWithUserQuery +package com.backgu.amaker.infra.jpa.chat.query interface ChatQueryRepository { fun findTopByChatRoomIdLittleThanCursorLimitCountWithUser( diff --git a/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/chat/repository/ChatQueryRepositoryImpl.kt b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/chat/query/ChatQueryRepositoryImpl.kt similarity index 94% rename from infra/src/main/kotlin/com/backgu/amaker/infra/jpa/chat/repository/ChatQueryRepositoryImpl.kt rename to infra/src/main/kotlin/com/backgu/amaker/infra/jpa/chat/query/ChatQueryRepositoryImpl.kt index cf9122d4..c70945eb 100644 --- a/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/chat/repository/ChatQueryRepositoryImpl.kt +++ b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/chat/query/ChatQueryRepositoryImpl.kt @@ -1,8 +1,6 @@ -package com.backgu.amaker.infra.jpa.chat.repository +package com.backgu.amaker.infra.jpa.chat.query import com.backgu.amaker.infra.jpa.chat.entity.QChatEntity.chatEntity -import com.backgu.amaker.infra.jpa.chat.query.ChatWithUserQuery -import com.backgu.amaker.infra.jpa.chat.query.QChatWithUserQuery import com.backgu.amaker.infra.jpa.user.entity.QUserEntity.userEntity import com.querydsl.jpa.impl.JPAQueryFactory diff --git a/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/chat/query/ChatRepository.kt b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/chat/query/ChatRepository.kt new file mode 100644 index 00000000..56479344 --- /dev/null +++ b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/chat/query/ChatRepository.kt @@ -0,0 +1,7 @@ +package com.backgu.amaker.infra.jpa.chat.query + +import com.backgu.amaker.infra.jpa.chat.repository.ChatJpaRepository + +interface ChatRepository : + ChatJpaRepository, + ChatQueryRepository diff --git a/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/chat/repository/ChatRepository.kt b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/chat/repository/ChatRepository.kt deleted file mode 100644 index 58913906..00000000 --- a/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/chat/repository/ChatRepository.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.backgu.amaker.infra.jpa.chat.repository - -interface ChatRepository : - ChatJpaRepository, - ChatQueryRepository diff --git a/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/chat/repository/ChatRoomUserRepository.kt b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/chat/repository/ChatRoomUserRepository.kt index 061ccec2..91706c5b 100644 --- a/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/chat/repository/ChatRoomUserRepository.kt +++ b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/chat/repository/ChatRoomUserRepository.kt @@ -2,6 +2,7 @@ package com.backgu.amaker.infra.jpa.chat.repository import com.backgu.amaker.infra.jpa.chat.entity.ChatRoomUserEntity import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query interface ChatRoomUserRepository : JpaRepository { fun existsByUserIdAndChatRoomId( @@ -24,4 +25,7 @@ interface ChatRoomUserRepository : JpaRepository { ): List fun findAllByChatRoomId(chatRoomId: Long): List + + @Query("select cru.userId from ChatRoomUser cru where cru.chatRoomId = :chatRoomId") + fun findUserIdsByChatRoomId(chatRoomId: Long): List } diff --git a/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/user/reposotory/UserRepository.kt b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/user/reposotory/UserRepository.kt index 20ad5b29..50eb4e7d 100644 --- a/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/user/reposotory/UserRepository.kt +++ b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/user/reposotory/UserRepository.kt @@ -15,4 +15,7 @@ interface UserRepository : JpaRepository { @Query("select u from User u join fetch WorkspaceUser wu on u.id = wu.userId where wu.workspaceId = :workspaceId") fun findByWorkspaceId(workspaceId: Long): List + + @Query("select u from User u join fetch ChatRoomUser cru on u.id = cru.userId where cru.chatRoomId = :chatRoomId") + fun findByChatRoomId(chatRoomId: Long): List } diff --git a/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/workspace/repository/WorkspaceRepository.kt b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/workspace/repository/WorkspaceRepository.kt index 4e59deb4..b2ff61c0 100644 --- a/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/workspace/repository/WorkspaceRepository.kt +++ b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/workspace/repository/WorkspaceRepository.kt @@ -26,7 +26,7 @@ interface WorkspaceRepository : JpaRepository { @Modifying @Query( "update Workspace w " + - "set w.belongingNumber = w.belongingNumber " + + "set w.belongingNumber = w.belongingNumber + 1 " + "where w.id = :workspaceId and w.belongingNumber < :limit", ) fun updateBelongingWithLimit( @@ -37,4 +37,7 @@ interface WorkspaceRepository : JpaRepository { @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select w from Workspace w where w.id = :id") fun getLockedWorkspaceById(id: Long): WorkspaceEntity? + + @Query("select ch.workspaceId from ChatRoom ch where ch.id = :chatRoomId") + fun getWorkspaceIdByChatRoomId(chatRoomId: Long): Long? } diff --git a/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/workspace/repository/WorkspaceUserRepository.kt b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/workspace/repository/WorkspaceUserRepository.kt index ee205c8d..11f79ae6 100644 --- a/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/workspace/repository/WorkspaceUserRepository.kt +++ b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/workspace/repository/WorkspaceUserRepository.kt @@ -7,7 +7,7 @@ import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param interface WorkspaceUserRepository : JpaRepository { - @Query("select wu.workspaceId from WorkspaceUser wu where wu.userId = :userId") + @Query("select wu.workspaceId from WorkspaceUser wu where wu.userId = :userId and wu.status = 'ACTIVE'") fun findWorkspaceIdsByUserId( @Param("userId") userId: String, ): List diff --git a/infra/src/main/kotlin/com/backgu/amaker/infra/mail/constants/EmailConstants.kt b/infra/src/main/kotlin/com/backgu/amaker/infra/mail/constants/EmailConstants.kt deleted file mode 100644 index 2961eaf8..00000000 --- a/infra/src/main/kotlin/com/backgu/amaker/infra/mail/constants/EmailConstants.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.backgu.amaker.infra.mail.constants - -enum class EmailConstants( - val templateName: String, - val title: String, - val content: String, -) { - WORKSPACE_INVITED("workspace-invite", "워크스페이스에 초대되었습니다.", "%s님은 %s 워크스페이스에 초대되었습니다."), - WORKSPACE_JOINED("workspace-join", "워크스페이스에 가입되었습니다.", "%s님은 %s 워크스페이스에 초대되었습니다."), - NOT_COMPLETED_NOTIFICATION("event-notification", "미완료된 이벤트", "[%s]가 마감 %s 전입니다. 맡은 업무를 완료해주세요."), - OVERDUE_NOTIFICATION("event-notification", "마감이 지난 이벤트", "[%s]의 마감 기한이 지났습니다. 맡은 업무를 완료해주세요."), -} diff --git a/infra/src/main/kotlin/com/backgu/amaker/infra/mail/service/EmailEventService.kt b/infra/src/main/kotlin/com/backgu/amaker/infra/mail/service/EmailEventService.kt deleted file mode 100644 index 0620343d..00000000 --- a/infra/src/main/kotlin/com/backgu/amaker/infra/mail/service/EmailEventService.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.backgu.amaker.infra.mail.service - -import com.backgu.amaker.domain.notifiacation.Notification -import org.springframework.context.ApplicationEventPublisher -import org.springframework.stereotype.Service - -@Service -class EmailEventService( - private val eventPublisher: ApplicationEventPublisher, -) { - fun publishEmailEvent(emailEvent: Notification) { - eventPublisher.publishEvent(emailEvent) - } -} diff --git a/infra/src/main/kotlin/com/backgu/amaker/infra/notification/kafka/service/KafkaNotificationEventService.kt b/infra/src/main/kotlin/com/backgu/amaker/infra/notification/kafka/service/KafkaNotificationEventService.kt index 9b299f9c..c5be8782 100644 --- a/infra/src/main/kotlin/com/backgu/amaker/infra/notification/kafka/service/KafkaNotificationEventService.kt +++ b/infra/src/main/kotlin/com/backgu/amaker/infra/notification/kafka/service/KafkaNotificationEventService.kt @@ -9,6 +9,6 @@ class KafkaNotificationEventService( private val kafkaTemplate: KafkaTemplate, ) : NotificationEventService { override fun publishNotificationEvent(notification: Notification) { - kafkaTemplate.send(KafkaConfig.NOTIFICATION_TOPIC, notification) + kafkaTemplate.send(KafkaConfig.NOTIFICATION_TOPIC, notification.getNotificationKey(), notification) } } diff --git a/infra/src/main/kotlin/com/backgu/amaker/infra/redis/chat/repository/ChatCacheRepository.kt b/infra/src/main/kotlin/com/backgu/amaker/infra/redis/chat/repository/ChatCacheRepository.kt index d3ebe28a..5e2118b0 100644 --- a/infra/src/main/kotlin/com/backgu/amaker/infra/redis/chat/repository/ChatCacheRepository.kt +++ b/infra/src/main/kotlin/com/backgu/amaker/infra/redis/chat/repository/ChatCacheRepository.kt @@ -27,7 +27,7 @@ class ChatCacheRepository( redisChatTemplate .opsForZSet() - .removeRange(key(chatRoomId), 0, -201) + .removeRange(key(chatRoomId), 0, -76) } fun updateFinishedCount( @@ -85,7 +85,7 @@ class ChatCacheRepository( ): List> = redisChatTemplate .opsForZSet() - .rangeByScore(key(chatRoomId), Double.NEGATIVE_INFINITY, (cursor - 1).toDouble(), 0, count.toLong()) + .reverseRangeByScore(key(chatRoomId), Double.NEGATIVE_INFINITY, (cursor - 1).toDouble(), 0, count.toLong()) ?.map { chat -> timeObjectMapper.readValue(chat, ChatWithUserCache::class.java) } ?: emptyList() @@ -97,7 +97,7 @@ class ChatCacheRepository( ): List> = redisChatTemplate .opsForZSet() - .rangeByScore(key(chatRoomId), (cursor + 1).toDouble(), Double.MAX_VALUE, 0, count.toLong()) + .rangeByScore(key(chatRoomId), (cursor + 1).toDouble(), Double.POSITIVE_INFINITY, 0, count.toLong()) ?.map { chat -> timeObjectMapper.readValue(chat, ChatWithUserCache::class.java) } ?: emptyList() diff --git a/infra/src/main/kotlin/com/backgu/amaker/infra/redis/chat/repository/ChatPipelinedQueryRepository.kt b/infra/src/main/kotlin/com/backgu/amaker/infra/redis/chat/repository/ChatPipelinedQueryRepository.kt new file mode 100644 index 00000000..01008ce9 --- /dev/null +++ b/infra/src/main/kotlin/com/backgu/amaker/infra/redis/chat/repository/ChatPipelinedQueryRepository.kt @@ -0,0 +1,64 @@ +package com.backgu.amaker.infra.redis.chat.repository + +import com.backgu.amaker.infra.redis.chat.data.ChatWithUserCache +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.core.script.RedisScript +import org.springframework.stereotype.Repository + +@Repository +class ChatPipelinedQueryRepository( + private val redisChatTemplate: RedisTemplate, + private val timeObjectMapper: ObjectMapper, +) { + companion object { + const val PREFIX = "chatRoom:" + + fun key(chatRoomId: Long) = "$PREFIX$chatRoomId" + + const val AFTER_CHAT_SCRIPT = + """ + local key = KEYS[1] + local cursor = tonumber(ARGV[1]) + local count = tonumber(ARGV[2]) + + local firstResult = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES') + + if #firstResult > 0 then + local firstChat = firstResult[1] + local firstScore = tonumber(firstResult[2]) -- 첫 번째 요소의 score 값을 가져옴 + + if firstScore > cursor then + local result = redis.call('ZRANGEBYSCORE', key, cursor + 1, math.huge, 'LIMIT', 0, count) + if #result > 0 then + return table.concat(result, ",") + else + return "" + end + else + return firstChat + end + else + return nil + end + """ + } + + fun findAfterChats( + chatRoomId: Long, + cursor: Long, + count: Int, + ): List>? { + val result: List = + redisChatTemplate.execute( + RedisScript.of(AFTER_CHAT_SCRIPT.trimIndent(), List::class.java as Class>), + listOf(key(chatRoomId)), + cursor.toString(), + count.toString(), + ) + + return result.map { chat -> + timeObjectMapper.readValue(chat, ChatWithUserCache::class.java) + } + } +} diff --git a/notification/build.gradle.kts b/notification/build.gradle.kts index b8675b7e..25e257f4 100644 --- a/notification/build.gradle.kts +++ b/notification/build.gradle.kts @@ -20,6 +20,10 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-redis") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("com.google.firebase:firebase-admin:6.8.1") + implementation("org.springframework.boot:spring-boot-starter-mail") + implementation("org.springframework.boot:spring-boot-starter-thymeleaf") + implementation("software.amazon.awssdk:ses:2.27.8") + implementation("org.apache.httpcomponents:httpclient:4.5.13") testImplementation(kotlin("test")) testImplementation("com.redis.testcontainers:testcontainers-redis-junit:1.6.4") diff --git a/notification/src/main/kotlin/com/backgu/amaker/notification/NotificationApplication.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/NotificationApplication.kt index ae5ce6bd..69b56204 100644 --- a/notification/src/main/kotlin/com/backgu/amaker/notification/NotificationApplication.kt +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/NotificationApplication.kt @@ -3,7 +3,6 @@ package com.backgu.amaker.notification import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.domain.EntityScan import org.springframework.boot.runApplication -import org.springframework.context.annotation.ComponentScan import org.springframework.data.jpa.repository.config.EnableJpaRepositories import org.springframework.data.redis.repository.configuration.EnableRedisRepositories import org.springframework.scheduling.annotation.EnableAsync @@ -11,9 +10,14 @@ import org.springframework.scheduling.annotation.EnableAsync @EnableAsync @SpringBootApplication @EntityScan(basePackages = ["com.backgu.amaker.infra"]) -@EnableJpaRepositories(basePackages = ["com.backgu.amaker.infra.jpa.user", "com.backgu.amaker.infra.jpa.workspace"]) +@EnableJpaRepositories( + basePackages = [ + "com.backgu.amaker.infra.jpa.user", + "com.backgu.amaker.infra.jpa.workspace", + "com.backgu.amaker.infra.jpa.chat.repository", + ], +) @EnableRedisRepositories(basePackages = ["com.backgu.amaker.infra.redis"]) -@ComponentScan("com.backgu.amaker.notification", "com.backgu.amaker.infra.mail") class NotificationApplication fun main(args: Array) { diff --git a/notification/src/main/kotlin/com/backgu/amaker/notification/chatroom/config/ChatRoomServiceConfig.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/chatroom/config/ChatRoomServiceConfig.kt new file mode 100644 index 00000000..88b96274 --- /dev/null +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/chatroom/config/ChatRoomServiceConfig.kt @@ -0,0 +1,12 @@ +package com.backgu.amaker.notification.chatroom.config + +import com.backgu.amaker.application.chat.service.ChatRoomUserService +import com.backgu.amaker.infra.jpa.chat.repository.ChatRoomUserRepository +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class ChatRoomServiceConfig { + @Bean + fun chatRoomUserService(chatRoomUserRepository: ChatRoomUserRepository) = ChatRoomUserService(chatRoomUserRepository) +} diff --git a/notification/src/main/kotlin/com/backgu/amaker/notification/config/ClusterConfigProperties.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/config/ClusterConfigProperties.kt new file mode 100644 index 00000000..f67649ac --- /dev/null +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/config/ClusterConfigProperties.kt @@ -0,0 +1,11 @@ +package com.backgu.amaker.notification.config + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Configuration + +@Configuration +@ConfigurationProperties(prefix = "spring.data.redis.cluster") +class ClusterConfigProperties { + lateinit var nodes: List + var maxRedirects: Int = 3 +} diff --git a/notification/src/main/kotlin/com/backgu/amaker/notification/config/ProdRedisConfig.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/config/ProdRedisConfig.kt new file mode 100644 index 00000000..6b53989c --- /dev/null +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/config/ProdRedisConfig.kt @@ -0,0 +1,77 @@ +package com.backgu.amaker.notification.config + +import com.backgu.amaker.infra.redis.session.SessionRedisData +import io.lettuce.core.SocketOptions +import io.lettuce.core.cluster.ClusterClientOptions +import io.lettuce.core.cluster.ClusterTopologyRefreshOptions +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import org.springframework.data.redis.connection.RedisClusterConfiguration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.connection.RedisPassword +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer +import org.springframework.data.redis.serializer.StringRedisSerializer +import java.time.Duration + +@Configuration +@Profile("prod") +@EnableRedisRepositories +class ProdRedisConfig( + private val clusterConfigProperties: ClusterConfigProperties, +) { + @Value("\${spring.data.redis.password}") + lateinit var password: String + + @Bean + fun redisConnectionFactory(): RedisConnectionFactory { + val clusterConfiguration = RedisClusterConfiguration(clusterConfigProperties.nodes) + clusterConfiguration.maxRedirects = clusterConfigProperties.maxRedirects + clusterConfiguration.password = RedisPassword.of(password) + + val socketOptions = + SocketOptions + .builder() + .connectTimeout(Duration.ofMillis(100L)) + .keepAlive(true) + .build() + + val clusterTopologyRefreshOptions = + ClusterTopologyRefreshOptions + .builder() + .dynamicRefreshSources(true) + .enableAllAdaptiveRefreshTriggers() + .enablePeriodicRefresh(Duration.ofMinutes(30L)) + .build() + + val clientOptions = + ClusterClientOptions + .builder() + .topologyRefreshOptions(clusterTopologyRefreshOptions) + .socketOptions(socketOptions) + .build() + + val clientConfiguration = + LettuceClientConfiguration + .builder() + .clientOptions(clientOptions) + .commandTimeout(Duration.ofMillis(3000L)) + .build() + + return LettuceConnectionFactory(clusterConfiguration, clientConfiguration) + } + + @Bean + fun redisTemplate(): RedisTemplate { + val template = RedisTemplate() + template.connectionFactory = redisConnectionFactory() + template.keySerializer = StringRedisSerializer() + template.valueSerializer = GenericJackson2JsonRedisSerializer() + return template + } +} diff --git a/notification/src/main/kotlin/com/backgu/amaker/notification/config/RedisConfig.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/config/RedisConfig.kt index 0e3ca05e..59cefe69 100644 --- a/notification/src/main/kotlin/com/backgu/amaker/notification/config/RedisConfig.kt +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/config/RedisConfig.kt @@ -4,12 +4,14 @@ import com.backgu.amaker.infra.redis.session.SessionRedisData import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile import org.springframework.data.redis.connection.RedisConnectionFactory import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory import org.springframework.data.redis.core.RedisTemplate import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer import org.springframework.data.redis.serializer.StringRedisSerializer +@Profile("!prod") @Configuration @ConfigurationProperties(prefix = "spring.data.redis") class RedisConfig { diff --git a/notification/src/main/kotlin/com/backgu/amaker/notification/email/config/EMailSenderConfig.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/email/config/EMailSenderConfig.kt new file mode 100644 index 00000000..d08ed003 --- /dev/null +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/email/config/EMailSenderConfig.kt @@ -0,0 +1,17 @@ +package com.backgu.amaker.notification.email.config + +import com.backgu.amaker.notification.email.service.EmailSender +import com.backgu.amaker.notification.email.ses.config.AWSSESConfig +import com.backgu.amaker.notification.email.ses.service.SESEmailService +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import software.amazon.awssdk.services.ses.SesClient + +@Configuration +class EMailSenderConfig { + @Bean + fun mailSender( + sesClient: SesClient, + sesConfig: AWSSESConfig, + ): EmailSender = SESEmailService(sesClient, sesConfig) +} diff --git a/infra/src/main/kotlin/com/backgu/amaker/infra/mail/config/MailClientConfig.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/email/gmail/config/MailClientConfig.kt similarity index 91% rename from infra/src/main/kotlin/com/backgu/amaker/infra/mail/config/MailClientConfig.kt rename to notification/src/main/kotlin/com/backgu/amaker/notification/email/gmail/config/MailClientConfig.kt index 2134bd3d..0f356449 100644 --- a/infra/src/main/kotlin/com/backgu/amaker/infra/mail/config/MailClientConfig.kt +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/email/gmail/config/MailClientConfig.kt @@ -1,4 +1,4 @@ -package com.backgu.amaker.infra.mail.config +package com.backgu.amaker.notification.email.gmail.config import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Configuration diff --git a/infra/src/main/kotlin/com/backgu/amaker/infra/mail/config/MailConstantsConfig.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/email/gmail/config/MailConstantsConfig.kt similarity index 94% rename from infra/src/main/kotlin/com/backgu/amaker/infra/mail/config/MailConstantsConfig.kt rename to notification/src/main/kotlin/com/backgu/amaker/notification/email/gmail/config/MailConstantsConfig.kt index 6ba92f98..62e7f09a 100644 --- a/infra/src/main/kotlin/com/backgu/amaker/infra/mail/config/MailConstantsConfig.kt +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/email/gmail/config/MailConstantsConfig.kt @@ -1,4 +1,4 @@ -package com.backgu.amaker.infra.mail.config +package com.backgu.amaker.notification.email.gmail.config import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration diff --git a/infra/src/main/kotlin/com/backgu/amaker/infra/mail/infra/HtmlEmailSender.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/email/gmail/infra/HtmlEmailSender.kt similarity index 84% rename from infra/src/main/kotlin/com/backgu/amaker/infra/mail/infra/HtmlEmailSender.kt rename to notification/src/main/kotlin/com/backgu/amaker/notification/email/gmail/infra/HtmlEmailSender.kt index 983ea14c..a1cf63e9 100644 --- a/infra/src/main/kotlin/com/backgu/amaker/infra/mail/infra/HtmlEmailSender.kt +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/email/gmail/infra/HtmlEmailSender.kt @@ -1,15 +1,13 @@ -package com.backgu.amaker.infra.mail.infra +package com.backgu.amaker.notification.email.gmail.infra -import com.backgu.amaker.application.notification.mail.service.EmailSender +import com.backgu.amaker.notification.email.service.EmailSender import io.github.oshai.kotlinlogging.KotlinLogging import jakarta.mail.internet.MimeMessage import org.springframework.mail.javamail.JavaMailSender import org.springframework.mail.javamail.MimeMessageHelper -import org.springframework.stereotype.Component private val logger = KotlinLogging.logger {} -@Component class HtmlEmailSender( val mailSender: JavaMailSender, ) : EmailSender { diff --git a/infra/src/main/kotlin/com/backgu/amaker/infra/mail/infra/ThymeleafEmailTemplateBuilder.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/email/gmail/infra/ThymeleafEmailTemplateBuilder.kt similarity index 86% rename from infra/src/main/kotlin/com/backgu/amaker/infra/mail/infra/ThymeleafEmailTemplateBuilder.kt rename to notification/src/main/kotlin/com/backgu/amaker/notification/email/gmail/infra/ThymeleafEmailTemplateBuilder.kt index 78980903..0db6e9e8 100644 --- a/infra/src/main/kotlin/com/backgu/amaker/infra/mail/infra/ThymeleafEmailTemplateBuilder.kt +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/email/gmail/infra/ThymeleafEmailTemplateBuilder.kt @@ -1,6 +1,6 @@ -package com.backgu.amaker.infra.mail.infra +package com.backgu.amaker.notification.email.gmail.infra -import com.backgu.amaker.application.notification.mail.service.EmailTemplateBuilder +import com.backgu.amaker.notification.email.service.EmailTemplateBuilder import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Component import org.thymeleaf.TemplateEngine diff --git a/infra/src/main/kotlin/com/backgu/amaker/application/notification/mail/service/EmailSender.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/email/service/EmailSender.kt similarity index 66% rename from infra/src/main/kotlin/com/backgu/amaker/application/notification/mail/service/EmailSender.kt rename to notification/src/main/kotlin/com/backgu/amaker/notification/email/service/EmailSender.kt index 9ff2ce62..d4dc2040 100644 --- a/infra/src/main/kotlin/com/backgu/amaker/application/notification/mail/service/EmailSender.kt +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/email/service/EmailSender.kt @@ -1,4 +1,4 @@ -package com.backgu.amaker.application.notification.mail.service +package com.backgu.amaker.notification.email.service interface EmailSender { fun sendEmail( diff --git a/infra/src/main/kotlin/com/backgu/amaker/application/notification/mail/service/EmailTemplateBuilder.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/email/service/EmailTemplateBuilder.kt similarity index 68% rename from infra/src/main/kotlin/com/backgu/amaker/application/notification/mail/service/EmailTemplateBuilder.kt rename to notification/src/main/kotlin/com/backgu/amaker/notification/email/service/EmailTemplateBuilder.kt index 07a0c81d..78972990 100644 --- a/infra/src/main/kotlin/com/backgu/amaker/application/notification/mail/service/EmailTemplateBuilder.kt +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/email/service/EmailTemplateBuilder.kt @@ -1,4 +1,4 @@ -package com.backgu.amaker.application.notification.mail.service +package com.backgu.amaker.notification.email.service interface EmailTemplateBuilder { fun buildEmailContent( diff --git a/notification/src/main/kotlin/com/backgu/amaker/notification/email/templated/TemplateEmailHandler.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/email/service/TemplateEmailHandler.kt similarity index 78% rename from notification/src/main/kotlin/com/backgu/amaker/notification/email/templated/TemplateEmailHandler.kt rename to notification/src/main/kotlin/com/backgu/amaker/notification/email/service/TemplateEmailHandler.kt index 5bd61171..b9af27a4 100644 --- a/notification/src/main/kotlin/com/backgu/amaker/notification/email/templated/TemplateEmailHandler.kt +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/email/service/TemplateEmailHandler.kt @@ -1,7 +1,5 @@ -package com.backgu.amaker.notification.email.templated +package com.backgu.amaker.notification.email.service -import com.backgu.amaker.application.notification.mail.service.EmailSender -import com.backgu.amaker.application.notification.mail.service.EmailTemplateBuilder import com.backgu.amaker.domain.notifiacation.method.TemplateEmailNotificationMethod import com.backgu.amaker.domain.user.User import org.springframework.stereotype.Service diff --git a/notification/src/main/kotlin/com/backgu/amaker/notification/email/ses/config/AWSSESConfig.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/email/ses/config/AWSSESConfig.kt new file mode 100644 index 00000000..711b513a --- /dev/null +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/email/ses/config/AWSSESConfig.kt @@ -0,0 +1,26 @@ +package com.backgu.amaker.notification.email.ses.config + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.ses.SesClient + +@Configuration +@ConfigurationProperties(prefix = "amazon.ses") +class AWSSESConfig { + lateinit var accessKey: String + lateinit var secretKey: String + lateinit var region: String + lateinit var sender: String + + @Bean + fun sesClient(): SesClient = + SesClient + .builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))) + .build() +} diff --git a/notification/src/main/kotlin/com/backgu/amaker/notification/email/ses/service/SESEmailService.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/email/ses/service/SESEmailService.kt new file mode 100644 index 00000000..f58610cb --- /dev/null +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/email/ses/service/SESEmailService.kt @@ -0,0 +1,73 @@ +package com.backgu.amaker.notification.email.ses.service + +import com.backgu.amaker.notification.email.service.EmailSender +import com.backgu.amaker.notification.email.ses.config.AWSSESConfig +import io.github.oshai.kotlinlogging.KotlinLogging +import software.amazon.awssdk.services.ses.SesClient +import software.amazon.awssdk.services.ses.model.Body +import software.amazon.awssdk.services.ses.model.Content +import software.amazon.awssdk.services.ses.model.Destination +import software.amazon.awssdk.services.ses.model.Message +import software.amazon.awssdk.services.ses.model.SendEmailRequest +import software.amazon.awssdk.services.ses.model.SesException + +private val logger = KotlinLogging.logger {} + +class SESEmailService( + private val sesClient: SesClient, + private val sesConfig: AWSSESConfig, +) : EmailSender { + override fun sendEmail( + emailAddress: String, + title: String, + body: String, + ) { + val destination: Destination = + Destination + .builder() + .toAddresses(emailAddress) + .build() + + val subjectContent: Content = + Content + .builder() + .data(title) + .charset("UTF-8") + .build() + + val bodyContent: Content = + Content + .builder() + .data(body) + .charset("UTF-8") + .build() + + val body: Body = + Body + .builder() + .html(bodyContent) + .build() + + val message: Message = + Message + .builder() + .subject(subjectContent) + .body(body) + .build() + + val request: SendEmailRequest = + SendEmailRequest + .builder() + .destination(destination) + .message(message) + .source(sesConfig.sender) + .build() + + try { + sesClient.sendEmail(request) + } catch (e: SesException) { + logger.error(e) { "Failed to send email to $emailAddress with title $title and body $body" } + throw e + } + } +} diff --git a/notification/src/main/kotlin/com/backgu/amaker/notification/realtime/handler/RealTimeHandler.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/realtime/handler/RealTimeHandler.kt index 6af89d93..2df140df 100644 --- a/notification/src/main/kotlin/com/backgu/amaker/notification/realtime/handler/RealTimeHandler.kt +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/realtime/handler/RealTimeHandler.kt @@ -10,10 +10,10 @@ class RealTimeHandler( private val realTimeCallService: RealTimeCallService, ) { fun handleUserRealTimeNotification( - userId: String, + userIds: List, realTimeServer: RealTimeServer, event: RealTimeBasedNotification, - ): List = realTimeCallService.sendUserRealTimeNotification(listOf(userId), realTimeServer, event) + ): List = realTimeCallService.sendUserRealTimeNotification(userIds, realTimeServer, event) fun handlerWorkspaceRealTimeNotification( workspaceId: Long, diff --git a/notification/src/main/kotlin/com/backgu/amaker/notification/service/adapter/RealTimeChatRoomHandlerAdapter.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/service/adapter/RealTimeChatRoomHandlerAdapter.kt new file mode 100644 index 00000000..b74db41b --- /dev/null +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/service/adapter/RealTimeChatRoomHandlerAdapter.kt @@ -0,0 +1,53 @@ +package com.backgu.amaker.notification.service.adapter + +import com.backgu.amaker.application.chat.service.ChatRoomUserService +import com.backgu.amaker.application.user.service.UserDeviceService +import com.backgu.amaker.application.workspace.WorkspaceService +import com.backgu.amaker.domain.notifiacation.ChatRoomNotification +import com.backgu.amaker.domain.notifiacation.Notification +import com.backgu.amaker.domain.notifiacation.method.NotificationMethod +import com.backgu.amaker.domain.notifiacation.method.RealTimeNotificationMethod +import com.backgu.amaker.domain.realtime.RealTimeServer +import com.backgu.amaker.domain.session.Session +import com.backgu.amaker.notification.realtime.handler.RealTimeHandler +import com.backgu.amaker.notification.realtime.service.RealTimeService +import com.backgu.amaker.notification.workspace.service.WorkspaceSessionService +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Component + +@Component +class RealTimeChatRoomHandlerAdapter( + private val realTimeHandler: RealTimeHandler, + private val realTimeService: RealTimeService, + private val workspaceSessionService: WorkspaceSessionService, + private val workspaceService: WorkspaceService, + private val chatRoomUserService: ChatRoomUserService, + private val userDeviceService: UserDeviceService, + private val applicationEventPublisher: ApplicationEventPublisher, +) : NotificationHandlerAdapter { + override fun supportsNotification(notification: Notification): Boolean = notification is ChatRoomNotification + + override fun supportsMethod(method: NotificationMethod): Boolean = method is RealTimeNotificationMethod + + override fun process( + notification: ChatRoomNotification, + method: RealTimeNotificationMethod, + ) { + val workspaceId: Long = workspaceService.getWorkspaceIdByChatRoomId(notification.chatRoomId) + val sessions: List = workspaceSessionService.findByWorkspaceId(workspaceId) + val realTimeServerSet: Set = + realTimeService.findByIdsToSet(sessions.mapTo(mutableSetOf()) { it.realtimeId }) + + val chatRoomUsers = chatRoomUserService.findUserIdsByChatRoomId(notification.chatRoomId) + val successUsers: List = + realTimeServerSet + .map { + realTimeHandler.handleUserRealTimeNotification(chatRoomUsers, it, notification) + }.flatten() + + val failedUsers: List = chatRoomUsers.filterNot { it in successUsers } + + val pushNotification = notification.toPushNotification(userDeviceService.findByUserIds(failedUsers)) + applicationEventPublisher.publishEvent(pushNotification) + } +} diff --git a/notification/src/main/kotlin/com/backgu/amaker/notification/service/adapter/RealTimeUserHandlerAdapter.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/service/adapter/RealTimeUserHandlerAdapter.kt index 7d82cac8..34597eba 100644 --- a/notification/src/main/kotlin/com/backgu/amaker/notification/service/adapter/RealTimeUserHandlerAdapter.kt +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/service/adapter/RealTimeUserHandlerAdapter.kt @@ -35,7 +35,7 @@ class RealTimeUserHandlerAdapter( val successUser = realTimeServerSet .map { - realTimeHandler.handleUserRealTimeNotification(notification.userId, it, notification) + realTimeHandler.handleUserRealTimeNotification(listOf(notification.userId), it, notification) }.flatten() if (successUser.isEmpty()) { diff --git a/notification/src/main/kotlin/com/backgu/amaker/notification/service/adapter/TemplateEmailChatRoomHandlerAdapter.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/service/adapter/TemplateEmailChatRoomHandlerAdapter.kt new file mode 100644 index 00000000..1373e109 --- /dev/null +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/service/adapter/TemplateEmailChatRoomHandlerAdapter.kt @@ -0,0 +1,27 @@ +package com.backgu.amaker.notification.service.adapter + +import com.backgu.amaker.application.user.service.UserService +import com.backgu.amaker.domain.notifiacation.ChatRoomNotification +import com.backgu.amaker.domain.notifiacation.Notification +import com.backgu.amaker.domain.notifiacation.method.NotificationMethod +import com.backgu.amaker.domain.notifiacation.method.TemplateEmailNotificationMethod +import com.backgu.amaker.notification.email.service.TemplateEmailHandler +import org.springframework.stereotype.Component + +@Component +class TemplateEmailChatRoomHandlerAdapter( + private val userService: UserService, + private val templateEmailHandler: TemplateEmailHandler, +) : NotificationHandlerAdapter { + override fun supportsNotification(notification: Notification): Boolean = notification is ChatRoomNotification + + override fun supportsMethod(method: NotificationMethod): Boolean = method is TemplateEmailNotificationMethod + + override fun process( + notification: ChatRoomNotification, + method: TemplateEmailNotificationMethod, + ) { + val users = userService.getByChatRoomId(notification.chatRoomId) + users.forEach { templateEmailHandler.handleEmailEvent(it, method) } + } +} diff --git a/notification/src/main/kotlin/com/backgu/amaker/notification/service/adapter/TemplateEmailUserFulfilledHandlerAdapter.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/service/adapter/TemplateEmailUserFulfilledHandlerAdapter.kt index 438ff0c6..bbf5b088 100644 --- a/notification/src/main/kotlin/com/backgu/amaker/notification/service/adapter/TemplateEmailUserFulfilledHandlerAdapter.kt +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/service/adapter/TemplateEmailUserFulfilledHandlerAdapter.kt @@ -4,7 +4,7 @@ import com.backgu.amaker.domain.notifiacation.Notification import com.backgu.amaker.domain.notifiacation.UserFulfilledNotification import com.backgu.amaker.domain.notifiacation.method.NotificationMethod import com.backgu.amaker.domain.notifiacation.method.TemplateEmailNotificationMethod -import com.backgu.amaker.notification.email.templated.TemplateEmailHandler +import com.backgu.amaker.notification.email.service.TemplateEmailHandler import org.springframework.stereotype.Component @Component diff --git a/notification/src/main/kotlin/com/backgu/amaker/notification/service/adapter/TemplateEmailUserHandlerAdapter.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/service/adapter/TemplateEmailUserHandlerAdapter.kt index 62a56db0..373416a8 100644 --- a/notification/src/main/kotlin/com/backgu/amaker/notification/service/adapter/TemplateEmailUserHandlerAdapter.kt +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/service/adapter/TemplateEmailUserHandlerAdapter.kt @@ -5,7 +5,7 @@ import com.backgu.amaker.domain.notifiacation.Notification import com.backgu.amaker.domain.notifiacation.UserNotification import com.backgu.amaker.domain.notifiacation.method.NotificationMethod import com.backgu.amaker.domain.notifiacation.method.TemplateEmailNotificationMethod -import com.backgu.amaker.notification.email.templated.TemplateEmailHandler +import com.backgu.amaker.notification.email.service.TemplateEmailHandler import org.springframework.stereotype.Component @Component diff --git a/notification/src/main/kotlin/com/backgu/amaker/notification/service/adapter/TemplateEmailWorkspaceHandlerAdapter.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/service/adapter/TemplateEmailWorkspaceHandlerAdapter.kt index ace3bbec..623e4a38 100644 --- a/notification/src/main/kotlin/com/backgu/amaker/notification/service/adapter/TemplateEmailWorkspaceHandlerAdapter.kt +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/service/adapter/TemplateEmailWorkspaceHandlerAdapter.kt @@ -5,7 +5,7 @@ import com.backgu.amaker.domain.notifiacation.Notification import com.backgu.amaker.domain.notifiacation.WorkspaceNotification import com.backgu.amaker.domain.notifiacation.method.NotificationMethod import com.backgu.amaker.domain.notifiacation.method.TemplateEmailNotificationMethod -import com.backgu.amaker.notification.email.templated.TemplateEmailHandler +import com.backgu.amaker.notification.email.service.TemplateEmailHandler import org.springframework.stereotype.Component @Component diff --git a/notification/src/main/kotlin/com/backgu/amaker/notification/workspace/config/WorkspaceServiceConfig.kt b/notification/src/main/kotlin/com/backgu/amaker/notification/workspace/config/WorkspaceServiceConfig.kt index 9f6b35cf..46fec260 100644 --- a/notification/src/main/kotlin/com/backgu/amaker/notification/workspace/config/WorkspaceServiceConfig.kt +++ b/notification/src/main/kotlin/com/backgu/amaker/notification/workspace/config/WorkspaceServiceConfig.kt @@ -1,12 +1,17 @@ package com.backgu.amaker.notification.workspace.config +import com.backgu.amaker.application.workspace.WorkspaceService import com.backgu.amaker.application.workspace.WorkspaceUserService +import com.backgu.amaker.infra.jpa.workspace.repository.WorkspaceRepository import com.backgu.amaker.infra.jpa.workspace.repository.WorkspaceUserRepository import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @Configuration class WorkspaceServiceConfig { + @Bean + fun workspaceService(workspaceRepository: WorkspaceRepository): WorkspaceService = WorkspaceService(workspaceRepository) + @Bean fun workspaceUserService(workspaceUserRepository: WorkspaceUserRepository): WorkspaceUserService = WorkspaceUserService(workspaceUserRepository) diff --git a/notification/src/main/resources/application.yaml b/notification/src/main/resources/application.yaml index 43983c50..847a7478 100644 --- a/notification/src/main/resources/application.yaml +++ b/notification/src/main/resources/application.yaml @@ -49,6 +49,13 @@ management: exposure: include: health,info,metrics,prometheus +amazon: + ses: + sender: ${EMAIL_SENDER} + access-key: ${AWS_SES_ACCESS} + secret-key: ${AWS_SES_SECRET} + region: ${AWS_REGION} + fcm: file: hi.json base-url: https://fcm.googleapis.com/v1/projects/a-maker-2b48b diff --git a/notification/src/main/resources/templates/chat-notification.html b/notification/src/main/resources/templates/chat-notification.html new file mode 100644 index 00000000..d6a42214 --- /dev/null +++ b/notification/src/main/resources/templates/chat-notification.html @@ -0,0 +1,10 @@ + + + + 새로운 채팅 + + +

+

+ + diff --git a/realtime/build.gradle.kts b/realtime/build.gradle.kts index 0ff2ae68..aca110bb 100644 --- a/realtime/build.gradle.kts +++ b/realtime/build.gradle.kts @@ -20,6 +20,9 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-data-redis") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("io.micrometer:micrometer-registry-prometheus") + testImplementation(kotlin("test")) testImplementation("com.redis.testcontainers:testcontainers-redis-junit:1.6.4") testImplementation("org.testcontainers:testcontainers:1.19.0") diff --git a/realtime/src/main/kotlin/com/backgu/amaker/realtime/config/ClusterConfigProperties.kt b/realtime/src/main/kotlin/com/backgu/amaker/realtime/config/ClusterConfigProperties.kt new file mode 100644 index 00000000..132381db --- /dev/null +++ b/realtime/src/main/kotlin/com/backgu/amaker/realtime/config/ClusterConfigProperties.kt @@ -0,0 +1,11 @@ +package com.backgu.amaker.realtime.config + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Configuration + +@Configuration +@ConfigurationProperties(prefix = "spring.data.redis.cluster") +class ClusterConfigProperties { + lateinit var nodes: List + var maxRedirects: Int = 3 +} diff --git a/realtime/src/main/kotlin/com/backgu/amaker/realtime/config/ProdRedisConfig.kt b/realtime/src/main/kotlin/com/backgu/amaker/realtime/config/ProdRedisConfig.kt new file mode 100644 index 00000000..618822bd --- /dev/null +++ b/realtime/src/main/kotlin/com/backgu/amaker/realtime/config/ProdRedisConfig.kt @@ -0,0 +1,77 @@ +package com.backgu.amaker.realtime.config + +import com.backgu.amaker.infra.redis.session.SessionRedisData +import io.lettuce.core.SocketOptions +import io.lettuce.core.cluster.ClusterClientOptions +import io.lettuce.core.cluster.ClusterTopologyRefreshOptions +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import org.springframework.data.redis.connection.RedisClusterConfiguration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.connection.RedisPassword +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer +import org.springframework.data.redis.serializer.StringRedisSerializer +import java.time.Duration + +@Configuration +@Profile("prod") +@EnableRedisRepositories +class ProdRedisConfig( + private val clusterConfigProperties: ClusterConfigProperties, +) { + @Value("\${spring.data.redis.password}") + lateinit var password: String + + @Bean + fun redisConnectionFactory(): RedisConnectionFactory { + val clusterConfiguration = RedisClusterConfiguration(clusterConfigProperties.nodes) + clusterConfiguration.maxRedirects = clusterConfigProperties.maxRedirects + clusterConfiguration.password = RedisPassword.of(password) + + val socketOptions = + SocketOptions + .builder() + .connectTimeout(Duration.ofMillis(100L)) + .keepAlive(true) + .build() + + val clusterTopologyRefreshOptions = + ClusterTopologyRefreshOptions + .builder() + .dynamicRefreshSources(true) + .enableAllAdaptiveRefreshTriggers() + .enablePeriodicRefresh(Duration.ofMinutes(30L)) + .build() + + val clientOptions = + ClusterClientOptions + .builder() + .topologyRefreshOptions(clusterTopologyRefreshOptions) + .socketOptions(socketOptions) + .build() + + val clientConfiguration = + LettuceClientConfiguration + .builder() + .clientOptions(clientOptions) + .commandTimeout(Duration.ofMillis(3000L)) + .build() + + return LettuceConnectionFactory(clusterConfiguration, clientConfiguration) + } + + @Bean + fun redisTemplate(): RedisTemplate { + val template = RedisTemplate() + template.connectionFactory = redisConnectionFactory() + template.keySerializer = StringRedisSerializer() + template.valueSerializer = GenericJackson2JsonRedisSerializer() + return template + } +} diff --git a/realtime/src/main/kotlin/com/backgu/amaker/realtime/config/RedisConfig.kt b/realtime/src/main/kotlin/com/backgu/amaker/realtime/config/RedisConfig.kt index 0ed0b42a..cba8fc8d 100644 --- a/realtime/src/main/kotlin/com/backgu/amaker/realtime/config/RedisConfig.kt +++ b/realtime/src/main/kotlin/com/backgu/amaker/realtime/config/RedisConfig.kt @@ -4,6 +4,7 @@ import com.backgu.amaker.infra.redis.session.SessionRedisData import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile import org.springframework.data.redis.connection.RedisConnectionFactory import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory import org.springframework.data.redis.core.RedisTemplate @@ -11,6 +12,7 @@ import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSeriali import org.springframework.data.redis.serializer.StringRedisSerializer @Configuration +@Profile("!prod") @ConfigurationProperties(prefix = "spring.data.redis") class RedisConfig { lateinit var host: String diff --git a/scripts/sql/ddl.sql b/scripts/sql/ddl.sql new file mode 100644 index 00000000..649b02ab --- /dev/null +++ b/scripts/sql/ddl.sql @@ -0,0 +1,185 @@ +create table chat +( + id bigint auto_increment + primary key, + created_at datetime(6) null, + updated_at datetime(6) null, + chat_room_id bigint not null, + chat_type enum ('FILE', 'GENERAL', 'IMAGE', 'REACTION', 'REPLY', 'TASK') null, + content text not null, + user_id varchar(255) not null +); + +create index idx_chat__chat_room_id + on chat (chat_room_id); + +create index idx_chat__user_id + on chat (user_id); + +create table chat_room +( + id bigint auto_increment + primary key, + created_at datetime(6) null, + updated_at datetime(6) null, + chat_room_type enum ('CUSTOM', 'DEFAULT') not null, + last_chat_id bigint null, + name varchar(255) not null, + workspace_id bigint not null +); + +create index idx_chat_room__workspace_id + on chat_room (workspace_id); + + + +create table chat_room_user +( + id bigint auto_increment + primary key, + created_at datetime(6) null, + updated_at datetime(6) null, + chat_room_id bigint not null, + last_read_chat_id bigint null, + user_id varchar(255) not null +); + +create index idx_chat_room_user__chat_room_id + on chat_room_user (chat_room_id); + +create index idx_chat_room_user__user_id_chat_room_id + on chat_room_user (user_id, chat_room_id); + +create table event +( + event_type varchar(31) not null, + id bigint not null + primary key, + created_at datetime(6) null, + updated_at datetime(6) null, + dead_line datetime(6) not null, + event_title varchar(255) not null, + notification_interval int not null, + notification_start_time datetime(6) not null +); + +create table event_assigned_user +( + id bigint auto_increment + primary key, + created_at datetime(6) null, + updated_at datetime(6) null, + event_id bigint not null, + is_finished bit not null, + user_id varchar(255) not null +); + +create index idx_event_assigned_user__event_id__user_id + on event_assigned_user (event_id, user_id); + +create table notification +( + notification_type varchar(31) not null, + id bigint auto_increment + primary key, + created_at datetime(6) null, + updated_at datetime(6) null, + content varchar(255) null, + title varchar(255) not null, + user_id varchar(255) not null +); + +create index idx_notification__user_id + on notification (user_id); + +create table event_notification +( + event_id bigint not null, + id bigint not null + primary key, + constraint FK3h54vfg4iyrq4n58rphha13su + foreign key (id) references notification (id) +); + +create index idx_event_notification__event_id + on event_notification (event_id); + +create table reply_comment +( + id bigint auto_increment + primary key, + created_at datetime(6) null, + updated_at datetime(6) null, + content varchar(255) not null, + event_id bigint not null, + user_id varchar(255) not null +); + +create index idx_reply_comment__event_id + on reply_comment (event_id); + +create table reply_event +( + event_details varchar(255) not null, + id bigint not null + primary key, + constraint FK96naaixsajjfdo28q9sx3spsj + foreign key (id) references event (id) +); + +create table user_device +( + id bigint auto_increment + primary key, + created_at datetime(6) null, + updated_at datetime(6) null, + device enum ('ANDROID', 'IOS') not null, + device_token varchar(255) not null, + user_id varchar(255) not null, + constraint UKm7th1gkgig3biayqgkd0sl4y0 + unique (device_token) +); + +create index idx_user_device__user_id + on user_device (user_id); + +create table users +( + id varchar(255) not null + primary key, + created_at datetime(6) null, + updated_at datetime(6) null, + email varchar(255) not null, + name varchar(255) not null, + picture varchar(255) not null, + user_role enum ('ADMIN', 'MANAGER', 'USER') not null, + constraint UK6dotkott2kjsp8vw4d0m25fb7 + unique (email) +); + +create table workspace +( + id bigint auto_increment + primary key, + created_at datetime(6) null, + updated_at datetime(6) null, + belonging_number int not null, + name varchar(255) not null, + thumbnail varchar(255) not null, + workspace_plan enum ('BASIC', 'ENTERPRISE') null +); + +create table workspace_user +( + id bigint auto_increment + primary key, + created_at datetime(6) null, + updated_at datetime(6) null, + status enum ('ACTIVE', 'PENDING') not null, + user_id varchar(255) not null, + workspace_id bigint not null, + workspace_role enum ('LEADER', 'MEMBER') not null +); + +create index idx_workspace_user__workspace_id + on workspace_user (user_id, workspace_id); \ No newline at end of file