Skip to content

Commit 331ef59

Browse files
authored
Merge pull request #28 from Nexters/feat/proposal-api
✨ Feature: proposal api
2 parents 254974a + f94ad51 commit 331ef59

File tree

52 files changed

+3047
-34
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+3047
-34
lines changed

docker/init.sql

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,35 @@ CREATE TABLE IF NOT EXISTS proposal
6464
INDEX idx_deleted_at (deleted_at)
6565
);
6666

67+
CREATE TABLE IF NOT EXISTS proposal_member
68+
(
69+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
70+
proposal_id BIGINT NOT NULL,
71+
member_id BIGINT NOT NULL,
72+
is_read BOOLEAN NOT NULL DEFAULT FALSE,
73+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
74+
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
75+
deleted_at TIMESTAMP NULL,
76+
77+
CONSTRAINT fk_proposal_member_proposal_id FOREIGN KEY (proposal_id) REFERENCES proposal (id),
78+
INDEX idx_proposal_member_id (proposal_id, member_id),
79+
INDEX idx_is_read_created_at (member_id, is_read ASC, created_at DESC),
80+
INDEX idx_deleted_at (deleted_at)
81+
);
82+
83+
CREATE TABLE IF NOT EXISTS purpose
84+
(
85+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
86+
type VARCHAR(50) NOT NULL,
87+
tag VARCHAR(255) NOT NULL,
88+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
89+
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
90+
deleted_at TIMESTAMP NULL,
91+
92+
INDEX idx_type (type),
93+
INDEX idx_deleted_at (deleted_at)
94+
);
95+
6796
CREATE TABLE IF NOT EXISTS category
6897
(
6998
id BIGINT AUTO_INCREMENT PRIMARY KEY,

gradle.properties

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ mockitoKotlinVersion=5.4.0
66
springMockkVersion=4.0.2
77
nimbusJwtVersion=10.3
88
quartzVersion=2.5.0
9-
firebaseVersion=9.5.0
9+
firebaseVersion=9.5.0
10+
queryDslVersion=5.0.0

tuk-api/build.gradle.kts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
plugins {
22
id("com.google.cloud.tools.jib")
3+
kotlin("kapt")
34
}
45

56
jib {
@@ -58,6 +59,13 @@ dependencies {
5859
// firebase
5960
implementation("com.google.firebase:firebase-admin:${properties["firebaseVersion"]}")
6061

62+
// queryDsl
63+
implementation("com.querydsl:querydsl-core:${properties["queryDslVersion"]}")
64+
implementation("com.querydsl:querydsl-jpa:${properties["queryDslVersion"]}:jakarta")
65+
kapt("com.querydsl:querydsl-apt:${properties["queryDslVersion"]}:jakarta")
66+
kapt("jakarta.annotation:jakarta.annotation-api")
67+
kapt("jakarta.persistence:jakarta.persistence-api")
68+
6169
// test
6270
testImplementation("org.springframework.boot:spring-boot-starter-test")
6371
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")

tuk-api/src/main/kotlin/nexters/tuk/application/gathering/GatheringQueryService.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import nexters.tuk.application.gathering.dto.response.GatheringResponse
55
import nexters.tuk.application.gathering.vo.RelativeTime
66
import nexters.tuk.application.proposal.ProposalService
77
import nexters.tuk.application.member.MemberService
8+
import nexters.tuk.contract.BaseException
9+
import nexters.tuk.contract.ErrorType
810
import nexters.tuk.domain.gathering.GatheringRepository
911
import nexters.tuk.domain.gathering.findByIdOrThrow
1012
import org.springframework.stereotype.Service
@@ -53,4 +55,13 @@ class GatheringQueryService(
5355
members = members
5456
)
5557
}
58+
59+
@Transactional(readOnly = true)
60+
fun getGatheringName(gatheringId: Long): GatheringResponse.GatheringName {
61+
val gathering = gatheringRepository.findById(gatheringId).orElseThrow {
62+
BaseException(ErrorType.NOT_FOUND, "찾을 수 없는 모임입니다.")
63+
}
64+
65+
return GatheringResponse.GatheringName(gathering.id, gathering.name)
66+
}
5667
}

tuk-api/src/main/kotlin/nexters/tuk/application/gathering/dto/response/GatheringResponse.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ class GatheringResponse {
5656
)
5757
}
5858

59+
data class GatheringName(
60+
val gatheringId: Long,
61+
val gatheringName: String
62+
)
63+
5964
data class GatheringMembers(
6065
val gatheringId: Long,
6166
val memberIds: List<Long>,

tuk-api/src/main/kotlin/nexters/tuk/application/gathering/vo/RelativeTime.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,33 @@
11
package nexters.tuk.application.gathering.vo
22

33
import com.fasterxml.jackson.annotation.JsonValue
4+
import java.time.LocalDateTime
5+
import java.time.temporal.ChronoUnit
46

57
@JvmInline
68
value class RelativeTime private constructor(@get:JsonValue val value: String) {
79
companion object {
10+
fun from(dateTime: LocalDateTime): RelativeTime {
11+
val now = LocalDateTime.now()
12+
val minutes = ChronoUnit.MINUTES.between(dateTime, now)
13+
val hours = ChronoUnit.HOURS.between(dateTime, now)
14+
val days = ChronoUnit.DAYS.between(dateTime, now)
15+
val weeks = days / 7
16+
val months = ChronoUnit.MONTHS.between(dateTime, now)
17+
val years = ChronoUnit.YEARS.between(dateTime, now)
18+
19+
return when {
20+
minutes < 1 -> RelativeTime("방금 전")
21+
minutes < 60 -> RelativeTime("${minutes}분 전")
22+
hours < 24 -> RelativeTime("${hours}시간 전")
23+
days == 0L -> RelativeTime("오늘")
24+
days < 7 -> RelativeTime("${days}일 전")
25+
days < 30 -> RelativeTime("${weeks}주 전")
26+
months < 12 -> RelativeTime("${months}개월 전")
27+
else -> RelativeTime("${years}년 전")
28+
}
29+
}
30+
831
fun fromDays(days: Int): RelativeTime {
932
val daysInWeek = 7
1033
val daysInMonth = 30
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package nexters.tuk.application.proposal
2+
3+
import nexters.tuk.application.gathering.GatheringMemberService
4+
import nexters.tuk.application.proposal.dto.request.ProposalCommand
5+
import nexters.tuk.application.proposal.dto.response.ProposalResponse
6+
import org.springframework.stereotype.Service
7+
import org.springframework.transaction.annotation.Transactional
8+
9+
@Service
10+
class ProposalCreateService(
11+
private val proposalService: ProposalService,
12+
private val proposalMemberService: ProposalMemberService,
13+
private val gatheringMemberService: GatheringMemberService,
14+
) {
15+
@Transactional
16+
fun propose(command: ProposalCommand.Propose): ProposalResponse.Propose {
17+
gatheringMemberService.verifyGatheringAccess(
18+
memberId = command.memberId,
19+
gatheringId = command.gatheringId
20+
)
21+
val proposal = proposalService.propose(command)
22+
val gatheringMembers = gatheringMemberService.getGatheringMemberIds(command.gatheringId)
23+
24+
proposalMemberService.publishGatheringMembers(
25+
proposalId = proposal.proposalId,
26+
memberIds = gatheringMembers
27+
)
28+
29+
return ProposalResponse.Propose(proposalId = proposal.proposalId)
30+
}
31+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package nexters.tuk.application.proposal
2+
3+
import nexters.tuk.application.proposal.dto.response.ProposalMemberResponse
4+
import nexters.tuk.contract.BaseException
5+
import nexters.tuk.contract.ErrorType
6+
import nexters.tuk.domain.proposal.ProposalMember
7+
import nexters.tuk.domain.proposal.ProposalMemberRepository
8+
import nexters.tuk.domain.proposal.ProposalRepository
9+
import org.springframework.stereotype.Service
10+
import org.springframework.transaction.annotation.Transactional
11+
12+
@Service
13+
class ProposalMemberService(
14+
private val proposalMemberRepository: ProposalMemberRepository,
15+
private val proposalRepository: ProposalRepository,
16+
) {
17+
@Transactional
18+
fun publishGatheringMembers(
19+
proposalId: Long, memberIds: List<Long>
20+
): ProposalMemberResponse.PublishedProposalMembers {
21+
22+
val proposal = proposalRepository.findById(proposalId)
23+
.orElseThrow { BaseException(ErrorType.NOT_FOUND, "찾을 수 없는 제안입니다.") }
24+
25+
val proposalMemberIds = memberIds.map {
26+
ProposalMember.publish(
27+
memberId = it,
28+
proposal = proposal,
29+
)
30+
}.let { proposalMemberRepository.saveAll(it) }.map { it.id }
31+
32+
return ProposalMemberResponse.PublishedProposalMembers(proposalMemberIds)
33+
}
34+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package nexters.tuk.application.proposal
2+
3+
import nexters.tuk.application.gathering.vo.RelativeTime
4+
import nexters.tuk.application.proposal.dto.request.ProposalQuery
5+
import nexters.tuk.application.proposal.dto.response.ProposalResponse
6+
import nexters.tuk.contract.BaseException
7+
import nexters.tuk.contract.ErrorType
8+
import nexters.tuk.contract.SliceDto.SliceResponse
9+
import nexters.tuk.domain.proposal.ProposalQueryRepository
10+
import org.springframework.stereotype.Service
11+
import org.springframework.transaction.annotation.Transactional
12+
13+
enum class ProposalDirection {
14+
SENT,
15+
RECEIVED,
16+
}
17+
18+
@Service
19+
class ProposalQueryService(
20+
private val proposalQueryRepository: ProposalQueryRepository,
21+
) {
22+
@Transactional(readOnly = true)
23+
fun getMemberProposals(query: ProposalQuery.MemberProposals): SliceResponse<ProposalResponse.ProposalOverview> {
24+
val memberProposals = proposalQueryRepository.findMemberProposals(
25+
memberId = query.memberId,
26+
page = query.page
27+
)
28+
29+
val proposalOverviews = memberProposals.map {
30+
ProposalResponse.ProposalOverview(
31+
proposalId = it.id,
32+
gatheringName = it.gatheringName,
33+
purpose = it.purpose,
34+
relativeTime = RelativeTime.from(it.createdAt)
35+
)
36+
}
37+
38+
return SliceResponse.from(proposalOverviews, query.page)
39+
}
40+
41+
@Transactional(readOnly = true)
42+
fun getGatheringProposals(query: ProposalQuery.GatheringProposals): SliceResponse<ProposalResponse.ProposalOverview> {
43+
val gatheringProposals = proposalQueryRepository.findGatheringProposals(
44+
query.memberId,
45+
query.gatheringId,
46+
query.type,
47+
query.page
48+
)
49+
50+
val proposalOverviews = gatheringProposals.map {
51+
ProposalResponse.ProposalOverview(
52+
proposalId = it.id,
53+
gatheringName = it.gatheringName,
54+
purpose = it.purpose,
55+
relativeTime = RelativeTime.from(it.createdAt)
56+
)
57+
}
58+
59+
return SliceResponse.from(proposalOverviews, query.page)
60+
}
61+
62+
@Transactional(readOnly = true)
63+
fun getProposal(proposalId: Long): ProposalResponse.ProposalDetail {
64+
val proposal = proposalQueryRepository.findProposalById(proposalId) ?: throw BaseException(
65+
ErrorType.NOT_FOUND, "존재하지 않는 만남 초대장입니다."
66+
)
67+
68+
return ProposalResponse.ProposalDetail(
69+
proposalId = proposal.id,
70+
gatheringId = proposal.gatheringId,
71+
gatheringName = proposal.gatheringName,
72+
purpose = proposal.purpose,
73+
relativeTime = RelativeTime.from(proposal.createdAt)
74+
)
75+
}
76+
}
Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,37 @@
11
package nexters.tuk.application.proposal
22

3+
import nexters.tuk.application.proposal.dto.request.ProposalCommand
34
import nexters.tuk.application.proposal.dto.response.ProposalResponse
4-
import nexters.tuk.domain.gathering.GatheringRepository
5-
import nexters.tuk.domain.gathering.findByIdOrThrow
5+
import nexters.tuk.domain.proposal.Proposal
66
import nexters.tuk.domain.proposal.ProposalRepository
77
import org.springframework.stereotype.Service
88
import org.springframework.transaction.annotation.Transactional
99

1010
@Service
1111
class ProposalService(
1212
private val proposalRepository: ProposalRepository,
13-
private val gatheringRepository: GatheringRepository,
1413
) {
1514
@Transactional(readOnly = true)
16-
fun getGatheringProposalStat(gatheringId: Long, memberId: Long): ProposalResponse.ProposalStat {
17-
val gathering = gatheringRepository.findByIdOrThrow(gatheringId)
18-
val proposals = proposalRepository.findByGathering(gathering).toList()
15+
fun getGatheringProposalStat(
16+
gatheringId: Long,
17+
memberId: Long
18+
): ProposalResponse.ProposalStat {
19+
val proposals = proposalRepository.findByGatheringId(gatheringId)
1920

2021
val sentCount = proposals.count { it.proposerId == memberId }
21-
val receivedCount = proposals.size - sentCount
22+
val receivedCount = proposals.count { it.proposerId != memberId }
2223

2324
return ProposalResponse.ProposalStat(sentCount, receivedCount)
2425
}
26+
27+
@Transactional
28+
fun propose(command: ProposalCommand.Propose): ProposalResponse.Propose {
29+
val proposal = Proposal.publish(
30+
gatheringId = command.gatheringId,
31+
proposerId = command.memberId,
32+
purpose = command.purpose.toString(),
33+
).let { proposalRepository.save(it) }
34+
35+
return ProposalResponse.Propose(proposalId = proposal.id)
36+
}
2537
}

0 commit comments

Comments
 (0)