diff --git a/src/main/kotlin/com/beat_it/global/config/SecurityConfig.kt b/src/main/kotlin/com/beat_it/global/config/SecurityConfig.kt index 94d2093..6c9cdd9 100644 --- a/src/main/kotlin/com/beat_it/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/beat_it/global/config/SecurityConfig.kt @@ -43,6 +43,7 @@ class SecurityConfig( .authorizeHttpRequests { auth -> auth .requestMatchers("/auth/**").permitAll() + .requestMatchers("/teams/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() .anyRequest().authenticated() } diff --git a/src/main/kotlin/com/beat_it/global/error/ErrorCode.kt b/src/main/kotlin/com/beat_it/global/error/ErrorCode.kt index 7ed8ae4..424c1b2 100644 --- a/src/main/kotlin/com/beat_it/global/error/ErrorCode.kt +++ b/src/main/kotlin/com/beat_it/global/error/ErrorCode.kt @@ -36,6 +36,16 @@ enum class ErrorCode( CALENDAR_START_TIME_REQUIRED(HttpStatus.BAD_REQUEST, "CALENDAR-008", "일정 시작 시각은 필수입니다."), CALENDAR_END_TIME_REQUIRED(HttpStatus.BAD_REQUEST, "CALENDAR-009", "일정 종료 시각은 필수입니다."), + // --- 팀 관련 에러 (TEAM) --- + TEAM_NO_CONTENT_TO_UPDATE(HttpStatus.BAD_REQUEST, "TEAM-001", "팀에 대해 변경할 내용이 업습니다."), + TEAM_NO_PERMISSION(HttpStatus.FORBIDDEN, "TEAM-002", "팀 조회 권한이 없습니다."), + TEAM_NOT_FOUND(HttpStatus.NOT_FOUND, "TEAM-003", "팀을 찾을 수 없습니다."), + TEAM_NAME_REQUIRED(HttpStatus.BAD_REQUEST, "TEAM-004", "팀 이름은 필수입니다."), + TEAM_NAME_TOO_LONG(HttpStatus.BAD_REQUEST, "TEAM-005", "팀 이름은 100자 이하여야 합니다."), + TEAM_DESCRIPTION_TOO_LONG(HttpStatus.BAD_REQUEST, "TEAM-006", "팀 설명은 500자 이하여야 합니다."), + TEAM_NO_UPDATE_PERMISSION(HttpStatus.FORBIDDEN, "TEAM-007","팀 수정 권한이 없습니다."), + + // --- 장소 관련 에러 --- LOCATION_NOT_FOUND(HttpStatus.NOT_FOUND, "LOCATION-001", "장소를 찾을 수 없습니다."), diff --git a/src/main/kotlin/com/beat_it/team/controller/TeamController.kt b/src/main/kotlin/com/beat_it/team/controller/TeamController.kt new file mode 100644 index 0000000..98211b5 --- /dev/null +++ b/src/main/kotlin/com/beat_it/team/controller/TeamController.kt @@ -0,0 +1,78 @@ +package com.beat_it.team.controller + +import com.beat_it.team.dto.TeamCreateRequest +import com.beat_it.team.dto.TeamCreateResponse +import com.beat_it.team.dto.TeamDetailResponse +import com.beat_it.team.dto.TeamDetailUpdateRequest +import com.beat_it.team.dto.TeamDetailUpdateResponse +import com.beat_it.team.service.TeamService +import com.beat_it.global.response.BasicResponse +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import java.util.UUID +import io.swagger.v3.oas.annotations.Parameter + +@RestController +@RequestMapping("/teams") +class TeamController( + private val teamService: TeamService +) { + + @PostMapping + fun createTeam( + @Parameter(hidden = true) + @RequestHeader("X-User-Public-Id") userPublicId: UUID, + + @RequestBody request: TeamCreateRequest + ): ResponseEntity> { + + val responseData = teamService.createTeam(userPublicId, request) + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(BasicResponse.success(responseData, HttpStatus.CREATED, "팀 생성에 성공했습니다.")) + } + + @PatchMapping + fun updateTeamDetail( + @Parameter(hidden = true) + @RequestHeader("X-User-Public-Id") userPublicId: UUID, + + @Parameter(hidden = true) + @RequestHeader("X-Team-Public-Id") teamPublicId: UUID, + + @RequestBody request: TeamDetailUpdateRequest, + ): ResponseEntity> { + val responseData = teamService.updateTeamDetail(teamPublicId, userPublicId, request) + return ResponseEntity.ok(BasicResponse.success(responseData, HttpStatus.OK, "팀 상세 내용이 수정되었습니다.")) + } + + @DeleteMapping + fun deleteTeam( + @Parameter(hidden = true) + @RequestHeader("X-User-Public-Id") userPublicId: UUID, + + @Parameter(hidden = true) + @RequestHeader("X-Team-Public-Id") teamPublicId: UUID, + + ): ResponseEntity> { + teamService.deleteTeam(teamPublicId, userPublicId) + return ResponseEntity.ok( + BasicResponse.success(HttpStatus.OK,"팀이 성공적으로 삭제되었습니다.") + ) + } + + @GetMapping + fun getTeamDetail( + @Parameter(hidden = true) + @RequestHeader("X-Team-Public-Id") teamPublicId: UUID, + + ): ResponseEntity> { + val responseData = teamService.getTeamDetail(teamPublicId) + return ResponseEntity.ok( + BasicResponse.success(responseData, HttpStatus.OK,"팀 상세 내용 조회에 성공했습니다.") + ) + } +} + diff --git a/src/main/kotlin/com/beat_it/team/dto/TeamCreateRequest.kt b/src/main/kotlin/com/beat_it/team/dto/TeamCreateRequest.kt new file mode 100644 index 0000000..259083e --- /dev/null +++ b/src/main/kotlin/com/beat_it/team/dto/TeamCreateRequest.kt @@ -0,0 +1,15 @@ +package com.beat_it.team.dto + +import com.beat_it.team.entity.enum.TeamType +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDate +import java.time.OffsetDateTime + +data class TeamCreateRequest( + val teamName: String, + val description: String?, + val teamType: TeamType, + val establishedOn: LocalDate?, + val profileImageUrl: String?, +) \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/team/dto/TeamCreateResponse.kt b/src/main/kotlin/com/beat_it/team/dto/TeamCreateResponse.kt new file mode 100644 index 0000000..b818812 --- /dev/null +++ b/src/main/kotlin/com/beat_it/team/dto/TeamCreateResponse.kt @@ -0,0 +1,15 @@ +package com.beat_it.team.dto + +import com.beat_it.team.entity.enum.TeamType +import com.fasterxml.jackson.annotation.JsonProperty +import java.time.OffsetDateTime + +data class TeamCreateResponse( + val teamId: Long, + val teamName: String, + val description: String?, + val inviteCode: String, + val teamType: TeamType, + val teamRole: String, + val createdAt: OffsetDateTime, +) \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/team/dto/TeamDetailResponse.kt b/src/main/kotlin/com/beat_it/team/dto/TeamDetailResponse.kt new file mode 100644 index 0000000..9955f61 --- /dev/null +++ b/src/main/kotlin/com/beat_it/team/dto/TeamDetailResponse.kt @@ -0,0 +1,33 @@ +package com.beat_it.team.dto + +import com.beat_it.team.entity.enum.PlatformCode +import java.time.LocalDate +import java.time.OffsetDateTime + +data class TeamDetailResponse( + val teamId: Long? = null, + val profileImageUrl: String?, + val teamName: String, + val description: String?, + val establishedOn: LocalDate?, + val inviteCode: String, + val memberCount: Int, + val createdAt: OffsetDateTime, + val updatedAt: OffsetDateTime, + val links: List, + val parts: List, + val archiveCount: Int, + val cloudItemCount: Int, +) + +data class LinksResponse( + val teamLinkId: Long, + val platformCode: PlatformCode, + val linkUrl: String, +) + +data class PartsResponse( + val teamPartId: Long, + val partName: String, + val displayOrder: Int, +) \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/team/dto/TeamDetailUpdateRequest.kt b/src/main/kotlin/com/beat_it/team/dto/TeamDetailUpdateRequest.kt new file mode 100644 index 0000000..e80ad87 --- /dev/null +++ b/src/main/kotlin/com/beat_it/team/dto/TeamDetailUpdateRequest.kt @@ -0,0 +1,19 @@ +package com.beat_it.team.dto + +import com.beat_it.team.entity.enum.PlatformCode +import com.beat_it.team.entity.enum.TeamType +import java.time.LocalDate + +data class TeamDetailUpdateRequest( + val teamName: String? = null, + val description: String? = null, + val teamType: TeamType? = null, + val establishedOn: LocalDate? = null, + val profileImageUrl: String? = null, + val links: List? = null, +) + +data class TeamLinksRequest( + val platformCode: PlatformCode, + val linkUrl: String, +) \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/team/dto/TeamDetailUpdateResponse.kt b/src/main/kotlin/com/beat_it/team/dto/TeamDetailUpdateResponse.kt new file mode 100644 index 0000000..a98c3aa --- /dev/null +++ b/src/main/kotlin/com/beat_it/team/dto/TeamDetailUpdateResponse.kt @@ -0,0 +1,13 @@ +package com.beat_it.team.dto + +import java.time.LocalDate +import java.time.OffsetDateTime + +data class TeamDetailUpdateResponse( + val teamId: Long, + val teamName: String, + val description: String?, + val establishedOn: LocalDate?, + val updatedAt: OffsetDateTime, + val links: List? = null, +) \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/team/entity/TeamLinks.kt b/src/main/kotlin/com/beat_it/team/entity/TeamLinks.kt new file mode 100644 index 0000000..a65c2e7 --- /dev/null +++ b/src/main/kotlin/com/beat_it/team/entity/TeamLinks.kt @@ -0,0 +1,41 @@ +package com.beat_it.team.entity + +import com.beat_it.global.entity.BaseUpdatedTimeEntity +import com.beat_it.team.entity.enum.PlatformCode +import com.beat_it.team.entity.enum.TeamRole +import io.swagger.v3.oas.annotations.links.Link +import jakarta.persistence.* +import java.time.OffsetDateTime + +@Entity +@Table(name = "team_links") +class TeamLinks( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name="team_link_id", nullable = false) + val teamLinkId: Long? = null, + + //TODO: 팀 ID 연결하기 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "team_id", nullable = false) + val team: Teams, + + @Enumerated(EnumType.STRING) + @Column(name="platform_code",nullable = false) + var platformCode: PlatformCode = PlatformCode.CUSTOM, + + @Column(name="link_url",nullable = false) + var linkUrl: String = "", + +// @Column(name="update_at",nullable = false) +// var updateAt: OffsetDateTime = OffsetDateTime.now(), +// +// @Column(name="create_at",nullable = false) +// val createdAt: OffsetDateTime = OffsetDateTime.now(), + +) : BaseUpdatedTimeEntity() { + fun updateLink(platformCode: PlatformCode, linkUrl: String) { + this.platformCode = platformCode + this.linkUrl = linkUrl + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/team/entity/TeamMemberships.kt b/src/main/kotlin/com/beat_it/team/entity/TeamMemberships.kt new file mode 100644 index 0000000..2146b92 --- /dev/null +++ b/src/main/kotlin/com/beat_it/team/entity/TeamMemberships.kt @@ -0,0 +1,38 @@ +package com.beat_it.team.entity + +import com.beat_it.global.entity.BaseUpdatedTimeEntity +import com.beat_it.team.entity.enum.TeamRole +import jakarta.persistence.* +import java.time.OffsetDateTime + +@Entity +@Table(name = "team_membership") +class TeamMemberships( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "team_membership_id") + val teamMembershipId: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "team_id", nullable = false) + val team: Teams, + + @Column(name = "user_id", nullable = false) + val userId: Long, + + @Enumerated(EnumType.STRING) + @Column(name = "team_role", nullable = false) + var teamRole: TeamRole = TeamRole.MEMBER, + + @Column(name = "left_at", nullable = true) + var leftAt: OffsetDateTime? = null, +) : BaseUpdatedTimeEntity() { + + fun updateTeamRole(teamRole: TeamRole) { + this.teamRole = teamRole + } + + fun leaveTeam() { + this.leftAt = OffsetDateTime.now() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/team/entity/TeamParts.kt b/src/main/kotlin/com/beat_it/team/entity/TeamParts.kt new file mode 100644 index 0000000..4aa5e94 --- /dev/null +++ b/src/main/kotlin/com/beat_it/team/entity/TeamParts.kt @@ -0,0 +1,50 @@ +package com.beat_it.team.entity + +import com.beat_it.global.entity.BaseUpdatedTimeEntity +import com.beat_it.team.entity.enum.TeamRole +import jakarta.persistence.* +import java.time.OffsetDateTime + +@Entity +@Table(name = "team_parts") +class TeamParts( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name="team_part_id", nullable = false) + val teamPartId: Long? = null, + + //TODO: 팀 ID 연결하기 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "team_id", nullable = false) + val team: Teams, + + @Column(name="part_name", nullable = false) + var partName: String = "", + + @Column(name="display_order", nullable = false) + var displayOrder: Int = 0, + + @Column(name="is_active", nullable = false) + var isActive: Boolean = true, + +// @Column(name="update_at", nullable = false) +// var updateAt: OffsetDateTime = OffsetDateTime.now(), +// +// @Column(name="create_at", nullable = false) +// val createdAt: OffsetDateTime = OffsetDateTime.now(), + + ) : BaseUpdatedTimeEntity() { + // TODO: 파트를 추가하는 함수를 넣어야 함. + fun updateTeamPart( + partName: String, + displayOrder: Int, + ) { + this.partName = partName + this.displayOrder = displayOrder + } + + fun deactivateTeamPart() { + this.isActive = false + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/team/entity/TeamSettings.kt b/src/main/kotlin/com/beat_it/team/entity/TeamSettings.kt new file mode 100644 index 0000000..a82c891 --- /dev/null +++ b/src/main/kotlin/com/beat_it/team/entity/TeamSettings.kt @@ -0,0 +1,27 @@ +package com.beat_it.team.entity + +import jakarta.persistence.* +import java.time.OffsetDateTime + +@Entity +@Table(name = "team_settings") +class TeamSettings( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name="team_settings_id") + val teamSettingId: Long? = null, + + //TODO: 팀 ID 연결하기 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "team_id", nullable = false) + val team: Teams, + + @Column(name="max_storage", nullable = false) + var maxStorage: Int = 10, + + @Column(name="used_storage", nullable = false) + var usedStorage: Int = 0, + + @Column(name="update_at", nullable = false) + var updateAt: OffsetDateTime = OffsetDateTime.now(), +) \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/team/entity/Teams.kt b/src/main/kotlin/com/beat_it/team/entity/Teams.kt new file mode 100644 index 0000000..6658649 --- /dev/null +++ b/src/main/kotlin/com/beat_it/team/entity/Teams.kt @@ -0,0 +1,60 @@ +package com.beat_it.team.entity +import com.beat_it.global.entity.BaseUpdatedTimeEntity +import com.beat_it.team.entity.enum.TeamType +import jakarta.persistence.* +import java.time.LocalDate +import java.time.OffsetDateTime +import java.util.UUID + +@Entity +@Table(name = "teams") +class Teams( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "team_id", nullable = false) + val teamId: Long? = null, + + @Column(name = "public_id", nullable = false, unique = true) + val publicId: UUID = UUID.randomUUID(), + + @Column(name = "profile_image_url", nullable = true) + var profileImageUrl: String? = null, + + @Column(name = "name", nullable = false) + var teamName: String, + + @Column(name = "description", nullable = true) + var description: String? = null, + + @Enumerated(EnumType.STRING) + @Column(name = "team_type", nullable = false) + var teamType: TeamType = TeamType.TEAM, + + @Column(name = "established_on", nullable = true) + var establishedOn: LocalDate? = null, + + @Column(name = "invite_code", nullable = false, unique = true) + val inviteCode: String, + + @Column(name = "deleted_at", nullable = true) + var deletedAt: OffsetDateTime? = null, + +) : BaseUpdatedTimeEntity() { + + fun updateTeamDetail( + teamName: String?, + description: String?, + establishedOn: LocalDate?, + teamType: TeamType?, + ) { + teamName?.let { this.teamName = it } + description?.let { this.description = it } + establishedOn?.let { this.establishedOn = it } + teamType?.let { this.teamType = it } + } + + + fun deleteTeam() { + this.deletedAt = OffsetDateTime.now() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/team/entity/enum/PlatformCode.kt b/src/main/kotlin/com/beat_it/team/entity/enum/PlatformCode.kt new file mode 100644 index 0000000..3b34a5c --- /dev/null +++ b/src/main/kotlin/com/beat_it/team/entity/enum/PlatformCode.kt @@ -0,0 +1,8 @@ +package com.beat_it.team.entity.enum + +enum class PlatformCode(val description : String) { + YOUTUBE("유튜브"), + INSTAGRAM("인스타"), + CUSTOM("기타"); +} + diff --git a/src/main/kotlin/com/beat_it/team/entity/enum/TeamRole.kt b/src/main/kotlin/com/beat_it/team/entity/enum/TeamRole.kt new file mode 100644 index 0000000..b2861ef --- /dev/null +++ b/src/main/kotlin/com/beat_it/team/entity/enum/TeamRole.kt @@ -0,0 +1,8 @@ +package com.beat_it.team.entity.enum + +enum class TeamRole(val description : String) { + LEADER("대표"), + MANAGER("운영진"), + MEMBER("팀원"); +} + diff --git a/src/main/kotlin/com/beat_it/team/entity/enum/TeamType.kt b/src/main/kotlin/com/beat_it/team/entity/enum/TeamType.kt new file mode 100644 index 0000000..9b21cb6 --- /dev/null +++ b/src/main/kotlin/com/beat_it/team/entity/enum/TeamType.kt @@ -0,0 +1,8 @@ +package com.beat_it.team.entity.enum + +enum class TeamType(val description : String) { + BAND("밴드"), + DANCE("댄스팀"), + VOCAL("보컬팀"), + TEAM("기본 팀"); +} \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/team/repository/TeamLinksRepository.kt b/src/main/kotlin/com/beat_it/team/repository/TeamLinksRepository.kt new file mode 100644 index 0000000..aa9b0bc --- /dev/null +++ b/src/main/kotlin/com/beat_it/team/repository/TeamLinksRepository.kt @@ -0,0 +1,17 @@ +package com.beat_it.team.repository + +import com.beat_it.team.entity.TeamLinks +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface TeamLinksRepository : JpaRepository { + + fun findAllByTeamTeamId( + teamId: Long, + ): List + + fun deleteAllByTeamTeamId( + teamId: Long, + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/team/repository/TeamMembershipRepository.kt b/src/main/kotlin/com/beat_it/team/repository/TeamMembershipRepository.kt new file mode 100644 index 0000000..4a973f0 --- /dev/null +++ b/src/main/kotlin/com/beat_it/team/repository/TeamMembershipRepository.kt @@ -0,0 +1,18 @@ +package com.beat_it.team.repository + +import com.beat_it.team.entity.TeamMemberships +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface TeamMembershipRepository : JpaRepository { + + fun findByTeamTeamIdAndUserIdAndLeftAtIsNull( + teamId: Long, + userId: Long, + ): TeamMemberships? + + fun countByTeamTeamIdAndLeftAtIsNull( + teamId: Long, + ): Int +} \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/team/repository/TeamPartsRepository.kt b/src/main/kotlin/com/beat_it/team/repository/TeamPartsRepository.kt new file mode 100644 index 0000000..57622c8 --- /dev/null +++ b/src/main/kotlin/com/beat_it/team/repository/TeamPartsRepository.kt @@ -0,0 +1,17 @@ +package com.beat_it.team.repository + +import com.beat_it.team.entity.TeamParts +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface TeamPartsRepository : JpaRepository { + + fun findAllByTeamTeamId( + teamId: Long, + ): List + + fun deleteAllByTeamTeamId( + teamId: Long, + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/team/repository/TeamRepository.kt b/src/main/kotlin/com/beat_it/team/repository/TeamRepository.kt new file mode 100644 index 0000000..8965561 --- /dev/null +++ b/src/main/kotlin/com/beat_it/team/repository/TeamRepository.kt @@ -0,0 +1,16 @@ +package com.beat_it.team.repository + +import com.beat_it.team.entity.Teams +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.util.UUID + +@Repository +interface TeamRepository : JpaRepository { + + fun findByPublicId(publicId: UUID): Teams? + + fun findByPublicIdAndDeletedAtIsNull(publicId: UUID): Teams? + + fun existsByInviteCode(inviteCode: String): Boolean +} \ No newline at end of file diff --git a/src/main/kotlin/com/beat_it/team/service/TeamService.kt b/src/main/kotlin/com/beat_it/team/service/TeamService.kt new file mode 100644 index 0000000..5f3e51f --- /dev/null +++ b/src/main/kotlin/com/beat_it/team/service/TeamService.kt @@ -0,0 +1,287 @@ +package com.beat_it.team.service + +import com.beat_it.auth.entity.Users +import com.beat_it.auth.repository.UserRepository +import com.beat_it.global.error.BusinessException +import com.beat_it.global.error.ErrorCode +import com.beat_it.team.dto.LinksResponse +import com.beat_it.team.dto.PartsResponse +import com.beat_it.team.dto.TeamCreateRequest +import com.beat_it.team.dto.TeamCreateResponse +import com.beat_it.team.dto.TeamDetailResponse +import com.beat_it.team.dto.TeamDetailUpdateRequest +import com.beat_it.team.dto.TeamDetailUpdateResponse +import com.beat_it.team.dto.TeamLinksRequest +import com.beat_it.team.entity.TeamLinks +import com.beat_it.team.entity.TeamMemberships +import com.beat_it.team.entity.Teams +import com.beat_it.team.entity.enum.TeamRole +import com.beat_it.team.repository.TeamLinksRepository +import com.beat_it.team.repository.TeamMembershipRepository +import com.beat_it.team.repository.TeamPartsRepository +import com.beat_it.team.repository.TeamRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Service +class TeamService( + private val userRepository: UserRepository, + private val teamRepository: TeamRepository, + private val teamLinksRepository: TeamLinksRepository, + private val teamPartsRepository: TeamPartsRepository, + private val teamMembershipRepository: TeamMembershipRepository, +) { + + @Transactional + fun createTeam(userPublicId: UUID, request: TeamCreateRequest): TeamCreateResponse { + validateCreateRequest(request) + + val userId = findUserOrThrow(userPublicId).userId!! + + val inviteCode = generateInviteCode() + + val team = Teams( + teamName = request.teamName, + description = request.description, + teamType = request.teamType, + establishedOn = request.establishedOn, + inviteCode = inviteCode + ) + + val savedTeam = teamRepository.save(team) + + // TODO: 팀 생성자를 TeamMember에 LEADER로 저장해야 함 + // 현재 createTeam 함수에 userId가 없기 때문에, 나중에 Controller에서 userId를 넘겨받는 구조가 필요함 + val leaderTeamMemberships = TeamMemberships( + team = savedTeam, + userId = userId, + teamRole = TeamRole.LEADER, + ) + + teamMembershipRepository.save(leaderTeamMemberships) + + return TeamCreateResponse( + teamId = savedTeam.teamId!!, + teamName = savedTeam.teamName, + description = savedTeam.description, + inviteCode = savedTeam.inviteCode, + teamType = savedTeam.teamType, + teamRole = "LEADER", + createdAt = savedTeam.createdAt + ) + } + + @Transactional + fun updateTeamDetail( + teamPublicId: UUID, + userPublicId: UUID, + request: TeamDetailUpdateRequest + ): TeamDetailUpdateResponse { + val team = findTeamOrThrow(teamPublicId) + val user = findUserOrThrow(userPublicId) + val teamId = team.teamId!! + + val currentLinks = teamLinksRepository.findAllByTeamTeamId(teamId) + + validateTeamUpdatePermission(teamId, user.userId!!) + validateUpdateRequest(request) + validateTeamDetailChanged(team, request, currentLinks) + + team.updateTeamDetail( + teamName = request.teamName, + description = request.description, + establishedOn = request.establishedOn, + teamType = request.teamType, + ) + + request.profileImageUrl?.let { + team.profileImageUrl = it + } + + request.links?.let { linkRequests -> + teamLinksRepository.deleteAllByTeamTeamId(teamId) + + val newLinks = linkRequests.map { linkRequest -> + TeamLinks( + team = team, + platformCode = linkRequest.platformCode, + linkUrl = linkRequest.linkUrl + ) + } + + teamLinksRepository.saveAll(newLinks) + } + + val links = teamLinksRepository.findAllByTeamTeamId(teamId) + .map { + LinksResponse( + teamLinkId = it.teamLinkId!!, + platformCode = it.platformCode, + linkUrl = it.linkUrl, + ) + } + + return TeamDetailUpdateResponse( + teamId = teamId, + teamName = team.teamName, + description = team.description, + establishedOn = team.establishedOn, + updatedAt = team.updatedAt, + links = links + ) + } + + @Transactional + fun deleteTeam( + teamPublicId: UUID, + userPublicId: UUID, + ) { + val team = findTeamOrThrow(teamPublicId) + val user = findUserOrThrow(userPublicId) + + validateTeamDeletePermission(team.teamId!!, user.userId!!) + + team.deleteTeam() + } + + @Transactional(readOnly = true) + fun getTeamDetail(teamPublicId: UUID): TeamDetailResponse { + val team = findTeamOrThrow(teamPublicId) + + val memberCount = teamMembershipRepository.countByTeamTeamIdAndLeftAtIsNull(team.teamId!!) + + val links = teamLinksRepository + .findAllByTeamTeamId(team.teamId!!) + .map { + LinksResponse( + teamLinkId = it.teamLinkId!!, + platformCode = it.platformCode, + linkUrl = it.linkUrl, + ) + } + + val parts = teamPartsRepository + .findAllByTeamTeamId(team.teamId!!) + .map { + PartsResponse( + teamPartId = it.teamPartId!!, + partName = it.partName, + displayOrder = it.displayOrder, + ) + } + + return TeamDetailResponse( + teamId = team.teamId, + profileImageUrl = team.profileImageUrl, + teamName = team.teamName, + description = team.description, + establishedOn = team.establishedOn, + inviteCode = team.inviteCode, + memberCount = memberCount, + createdAt = team.createdAt, + updatedAt = team.updatedAt, + links = links, + parts = parts, + archiveCount = 0, + cloudItemCount = 0 + ) + } + + private fun findUserOrThrow(userPublicId: UUID) : Users { + return userRepository.findByPublicId(userPublicId) + ?: throw BusinessException(ErrorCode.USER_NOT_FOUND) + } + + private fun findTeamOrThrow(teamPublicId: UUID): Teams { + return teamRepository.findByPublicIdAndDeletedAtIsNull(teamPublicId) + ?: throw BusinessException(ErrorCode.TEAM_NOT_FOUND) + } + + private fun validateCreateRequest(request: TeamCreateRequest) { + if (request.teamName.isBlank()) { + throw BusinessException(ErrorCode.TEAM_NAME_REQUIRED) + } + + if (request.teamName.length > 100) { + throw BusinessException(ErrorCode.TEAM_NAME_TOO_LONG) + } + + if ((request.description?.length ?: 0) > 500) { + throw BusinessException(ErrorCode.TEAM_DESCRIPTION_TOO_LONG) + } + } + + private fun validateUpdateRequest(request: TeamDetailUpdateRequest) { + if ((request.teamName?.length ?: 0) > 100) { + throw BusinessException(ErrorCode.TEAM_NAME_TOO_LONG) + } + + if ((request.description?.length ?: 0) > 500) { + throw BusinessException(ErrorCode.TEAM_DESCRIPTION_TOO_LONG) + } + } + + private fun validateTeamDetailChanged( + team: Teams, + request: TeamDetailUpdateRequest, + currentLinks: List + ) { + val isAnyFieldChanged = + (request.teamName != null && request.teamName != team.teamName) || + (request.description != null && request.description != team.description) || + (request.establishedOn != null && request.establishedOn != team.establishedOn) || + (request.teamType != null && request.teamType != team.teamType) || + (request.profileImageUrl != null && request.profileImageUrl != team.profileImageUrl) + + val isLinksChanged = + request.links != null && !isLinksSame(currentLinks, request.links) + + if (!isAnyFieldChanged && !isLinksChanged) { + throw BusinessException(ErrorCode.TEAM_NO_CONTENT_TO_UPDATE) + } + } + + private fun isLinksSame( + currentLinks: List, + requestLinks: List + ) : Boolean { + val current = currentLinks + .map { it.platformCode to it.linkUrl } + .sortedWith(compareBy({it.first.toString()}, { it.second })) + + val requested = requestLinks + .map { it.platformCode to it.linkUrl } + .sortedWith(compareBy({it.first.toString()}, { it.second })) + + return current == requested + } + + + + private fun validateTeamUpdatePermission(teamId: Long, userId: Long) { + // TODO: TeamMemberRepository가 생기면 여기서 LEADER 또는 MANAGER인지 확인 + val membership = teamMembershipRepository.findByTeamTeamIdAndUserIdAndLeftAtIsNull(teamId, userId) ?: throw BusinessException(ErrorCode.TEAM_NO_PERMISSION) + if (membership.teamRole != TeamRole.LEADER && membership.teamRole != TeamRole.MANAGER) { + throw BusinessException(ErrorCode.TEAM_NO_PERMISSION) + } + } + + private fun validateTeamDeletePermission(teamId: Long, userId: Long) { + // TODO: 팀 삭제 권한 검증 + val membership = teamMembershipRepository.findByTeamTeamIdAndUserIdAndLeftAtIsNull(teamId, userId) ?: throw BusinessException(ErrorCode.TEAM_NO_PERMISSION) + if (membership.teamRole != TeamRole.LEADER) { + throw BusinessException(ErrorCode.TEAM_NO_PERMISSION) + } + } + + private fun generateInviteCode(): String { + // TODO: 팀 초대코드 생성법 고안 필요 + // TODO: Redis로 초대코드를 구성하는 블로그 참고하기 + return "BEATIT-" + UUID.randomUUID() + .toString() + .replace("-", "") + .take(6) + .uppercase() + } +} \ No newline at end of file