Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/main/java/com/opus/opus/docs/asciidoc/opus.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ endif::[]
link:./member.html[회원 API]

== 팀 관련 API

link:./team.html[팀 API]

link:./team-comment.html[팀 댓글 API]

link:./team-member.html[팀원 API]

link:./team-like.html[팀 좋아요 API]

== 공지 관련 API
link:./notice.html[공지 API]

Expand Down
140 changes: 140 additions & 0 deletions src/main/java/com/opus/opus/docs/asciidoc/team-like.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
ifndef::snippets[]
:snippets: ./build/generated-snippets
endif::[]

= TEAM LIKE API 문서
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 3
:sectnums:

== API 목록

link:./opus.html[API 목록으로 돌아가기]

== `PUT`: 팀 좋아요 토글

해당 팀에 대해 좋아요(찜) 상태를 토글합니다.

* `isLiked: true` → 좋아요 등록
* `isLiked: false` → 좋아요 취소

NOTE: 좋아요를 통해 팀을 찜할 수 있습니다.

NOTE: 투표 기간에는 투표만 가능하고, 투표 기간이 아닐 때만 좋아요가 가능합니다.

.Path Parameters
include::{snippets}/toggle-team-like/path-parameters.adoc[]

.HTTP Request Headers
include::{snippets}/toggle-team-like/request-headers.adoc[]

.HTTP Request
include::{snippets}/toggle-team-like/http-request.adoc[]

.HTTP Response
include::{snippets}/toggle-team-like/http-response.adoc[]

.Request Fields
include::{snippets}/toggle-team-like/request-fields.adoc[]

.Response Fields
include::{snippets}/toggle-team-like/response-fields.adoc[]

=== 시나리오별 응답

==== 시나리오 1: TeamLike 데이터가 없는 경우

특정 멤버가 특정 팀에 대해 좋아요 API를 처음 호출하면, TeamLike 테이블에 데이터가 새로 생성됩니다.

[cols="1,2,1"]
|===
|Request isLiked |응답 메시지 |HTTP 상태 코드

|true
|좋아요가 등록되었습니다.
|200 OK

|false
|아직 좋아요하지 않은 팀입니다.
|400 Bad Request
|===

==== 시나리오 2: TeamLike 데이터가 있는 경우

이미 해당 팀에 대한 좋아요 기록이 있는 경우, 상태에 따라 토글됩니다.

[cols="1,1,2,1"]
|===
|현재 isLiked |Request isLiked |응답 메시지 |HTTP 상태 코드

|true
|true
|이미 좋아요한 팀입니다.
|400 Bad Request

|true
|false
|좋아요가 취소되었습니다.
|200 OK

|false
|true
|좋아요가 등록되었습니다.
|200 OK

|false
|false
|이미 좋아요를 취소한 팀입니다.
|400 Bad Request
|===


=== ⚠️ 실패 케이스

.❌ Case 1: 존재하지 않는 팀

[%collapsible]
====
include::{snippets}/toggle-team-like-fail-not-found/http-request.adoc[]

include::{snippets}/toggle-team-like-fail-not-found/http-response.adoc[]
====

.❌ Case 2: 이미 좋아요한 팀

[%collapsible]
====
include::{snippets}/toggle-team-like-fail-already-liked/http-request.adoc[]

include::{snippets}/toggle-team-like-fail-already-liked/http-response.adoc[]
====

.❌ Case 3: 이미 좋아요 취소한 팀

[%collapsible]
====
include::{snippets}/toggle-team-like-fail-already-unliked/http-request.adoc[]

include::{snippets}/toggle-team-like-fail-already-unliked/http-response.adoc[]
====

.❌ Case 4: 좋아요한 적 없는 팀에 취소 요청

[%collapsible]
====
include::{snippets}/toggle-team-like-fail-not-liked-yet/http-request.adoc[]

include::{snippets}/toggle-team-like-fail-not-liked-yet/http-response.adoc[]
====

.❌ Case 5: 투표 기간 중 좋아요 요청

[%collapsible]
====
include::{snippets}/toggle-team-like-fail-voting-period/http-request.adoc[]

include::{snippets}/toggle-team-like-fail-voting-period/http-response.adoc[]
====
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import static com.opus.opus.modules.contest.exception.ContestExceptionType.*;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.ALREADY_CURRENT_CONTEST;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.ALREADY_NOT_CURRENT_CONTEST;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.CURRENT_CONTEST_LIMIT_EXCEEDED;
import static com.opus.opus.modules.file.domain.FileImageType.BANNER;
import static com.opus.opus.modules.file.domain.ReferenceDomainType.CONTEST;
Expand All @@ -17,12 +16,10 @@
import com.opus.opus.modules.contest.application.dto.request.VoteUpdateRequest;
import com.opus.opus.modules.contest.application.dto.response.ContestCurrentToggleResponse;
import com.opus.opus.modules.contest.application.dto.response.ContestResponse;
import com.opus.opus.modules.contest.application.dto.response.VotePeriodResponse;
import com.opus.opus.modules.contest.domain.Contest;
import com.opus.opus.modules.contest.domain.ContestCategory;
import com.opus.opus.modules.contest.domain.dao.ContestRepository;
import com.opus.opus.modules.contest.exception.ContestException;
import com.opus.opus.modules.contest.exception.ContestExceptionType;
import com.opus.opus.modules.file.domain.File;
import com.opus.opus.modules.file.domain.dao.FileRepository;
import com.opus.opus.modules.file.exception.FileException;
Expand Down Expand Up @@ -122,17 +119,11 @@ private void checkVoteRange(final VoteUpdateRequest voteRequest) {
public void updateMaxVotesLimit(final Long contestId, final Integer maxVotesLimit) {
final Contest contest = contestConvenience.getValidateExistContest(contestId);

validateNotInVotingPeriod(contest);
contestConvenience.validateNotInVotingPeriod(contest);

contest.updateMaxVotesLimit(maxVotesLimit);
}

private void validateNotInVotingPeriod(final Contest contest) {
if (contest.isVotingPeriod()) {
throw new ContestException(CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD);
}
}

private void checkWebpConverted(File existingFile) {
if (!existingFile.getIsWebpConverted()) {
throw new FileException(NOT_WEBP_CONVERTED);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.opus.opus.modules.contest.application.convenience;


import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_ALLOWED_DURING_VOTING_PERIOD;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.CATEGORY_HAS_CONTEST;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.CONTEST_NAME_ALREADY_EXIST;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_FOUND_CONTEST;
Expand Down Expand Up @@ -47,4 +48,10 @@ public long countCurrentContests() {
public List<Contest> getCurrentContests() {
return contestRepository.findAllByIsCurrentTrue();
}

public void validateNotInVotingPeriod(final Contest contest) {
if (contest.isVotingPeriod()) {
throw new ContestException(NOT_ALLOWED_DURING_VOTING_PERIOD);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public enum ContestExceptionType implements BaseExceptionType {
CATEGORY_HAS_CONTEST(HttpStatus.CONFLICT, "해당 카테고리에 속한 대회가 존재합니다."),
CONTEST_NAME_ALREADY_EXIST(HttpStatus.CONFLICT, "동일한 대회명이 있습니다."),
VOTE_END_PRECEDE_VOTE_START(HttpStatus.BAD_REQUEST, "투표 종료가 투표 시작보다 빠를 수 없습니다."),
CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD(HttpStatus.BAD_REQUEST, "투표 진행중에는 최대 투표 개수를 변경할 수 없습니다.")
NOT_ALLOWED_DURING_VOTING_PERIOD(HttpStatus.BAD_REQUEST, "현재 투표 기간이므로 해당 작업을 수행할 수 없습니다.")
;

private final HttpStatus httpStatus;
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/com/opus/opus/modules/team/api/TeamController.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package com.opus.opus.modules.team.api;

import com.opus.opus.global.security.annotation.LoginMember;
import com.opus.opus.modules.member.domain.Member;
import com.opus.opus.modules.team.application.TeamCommandService;
import com.opus.opus.modules.team.application.TeamQueryService;
import com.opus.opus.modules.team.application.dto.ImageResponse;
import com.opus.opus.modules.team.application.dto.request.PreviewDeleteRequest;
import com.opus.opus.modules.team.application.dto.request.TeamLikeToggleRequest;
import com.opus.opus.modules.team.application.dto.response.TeamLikeToggleResponse;
import jakarta.validation.Valid;
import java.util.List;
import lombok.RequiredArgsConstructor;
Expand All @@ -16,6 +20,7 @@
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
Expand Down Expand Up @@ -103,4 +108,13 @@ public ResponseEntity<Void> deletePosterImage(@PathVariable final Long teamId) {
teamCommandService.deletePosterImage(teamId);
return ResponseEntity.noContent().build();
}

@Secured({"ROLE_회원", "ROLE_관리자"})
@PutMapping("/{teamId}/likes")
public ResponseEntity<TeamLikeToggleResponse> toggleLike(@PathVariable final Long teamId,
@RequestBody @Valid final TeamLikeToggleRequest request,
@LoginMember final Member member) {
final TeamLikeToggleResponse response = teamCommandService.toggleLike(member.getId(), teamId, request.isLiked());
return ResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,26 @@
import static com.opus.opus.modules.file.domain.ReferenceDomainType.TEAM;
import static com.opus.opus.modules.file.exception.FileExceptionType.EXCEED_PREVIEW_LIMIT;
import static com.opus.opus.modules.file.exception.FileExceptionType.NOT_WEBP_CONVERTED;
import static com.opus.opus.modules.team.exception.TeamLikeExceptionType.ALREADY_LIKED;
import static com.opus.opus.modules.team.exception.TeamLikeExceptionType.ALREADY_UNLIKED;
import static com.opus.opus.modules.team.exception.TeamLikeExceptionType.NOT_LIKED_YET;

import com.opus.opus.global.util.FileStorageUtil;
import com.opus.opus.modules.contest.application.convenience.ContestConvenience;
import com.opus.opus.modules.contest.domain.Contest;
import com.opus.opus.modules.file.domain.File;
import com.opus.opus.modules.file.domain.FileImageType;
import com.opus.opus.modules.file.domain.dao.FileRepository;
import com.opus.opus.modules.file.exception.FileException;
import com.opus.opus.modules.team.application.convenience.TeamConvenience;
import com.opus.opus.modules.team.application.dto.response.TeamLikeToggleResponse;
import com.opus.opus.modules.team.domain.Team;
import com.opus.opus.modules.team.domain.TeamLike;
import com.opus.opus.modules.team.domain.dao.TeamLikeRepository;
import com.opus.opus.modules.team.exception.TeamLikeException;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -28,6 +40,9 @@ public class TeamCommandService {

private final FileRepository fileRepository;
private final TeamConvenience teamConvenience;
private final ContestConvenience contestConvenience;

private final TeamLikeRepository teamLikeRepository;


public void savePreviewImages(final Long teamId, final List<MultipartFile> images) {
Expand Down Expand Up @@ -65,6 +80,45 @@ public void deletePosterImage(final Long teamId) {
deleteIfExists(teamId, POSTER);
}

public TeamLikeToggleResponse toggleLike(final Long memberId, final Long teamId, final Boolean isLiked) {
final Team team = teamConvenience.getValidateExistTeam(teamId);
final Contest contest = contestConvenience.getValidateExistContest(team.getContestId());

contestConvenience.validateNotInVotingPeriod(contest);

final Optional<TeamLike> teamLikeOptional = teamLikeRepository.findByMemberIdAndTeam(memberId, team);
return teamLikeOptional
.map(teamLike -> handleExistingLike(teamLike, isLiked))
.orElseGet(() -> handleFirstTimeLike(memberId, team, isLiked));
}

private TeamLikeToggleResponse handleFirstTimeLike(final Long memberId, final Team team, final Boolean isLiked) {
if (!isLiked) {
throw new TeamLikeException(NOT_LIKED_YET);
}

saveTeamLike(memberId, team);
return TeamLikeToggleResponse.of(team.getId(), true, "좋아요가 등록되었습니다.");
}

private TeamLikeToggleResponse handleExistingLike(final TeamLike teamLike, final Boolean isLiked) {
if (Objects.equals(teamLike.getIsLiked(), isLiked)) {
throw new TeamLikeException(isLiked ? ALREADY_LIKED : ALREADY_UNLIKED);
}

teamLike.updateIsLiked(isLiked);

return TeamLikeToggleResponse.of(teamLike.getTeam().getId(), isLiked, isLiked ? "좋아요가 등록되었습니다." : "좋아요가 취소되었습니다.");
}

private void saveTeamLike(final Long memberId, final Team team) {
teamLikeRepository.save(TeamLike.builder()
.memberId(memberId)
.team(team)
.isLiked(true)
.build());
}

private void deleteIfExists(final Long teamId, final FileImageType imageType) {
fileRepository.findByReferenceIdAndReferenceTypeAndImageType(teamId, TEAM, imageType)
.ifPresent(existingFile -> fileStorageUtil.deleteFile(existingFile.getId()));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.opus.opus.modules.team.application.dto.request;

import jakarta.validation.constraints.NotNull;

public record TeamLikeToggleRequest(
@NotNull(message = "isLiked 값은 필수입니다.")
Boolean isLiked
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.opus.opus.modules.team.application.dto.response;

public record TeamLikeToggleResponse(
Long teamId,
Boolean isLiked,
String message
) {
public static TeamLikeToggleResponse of(Long teamId, Boolean isLiked, String message) {
return new TeamLikeToggleResponse(teamId, isLiked, message);
}
}
8 changes: 8 additions & 0 deletions src/main/java/com/opus/opus/modules/team/domain/TeamLike.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
Expand All @@ -18,6 +20,9 @@
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(uniqueConstraints = {
@UniqueConstraint(name = "uk_team_like_member_team", columnNames = {"member_id", "team_id"})
})
public class TeamLike extends BaseEntity {

@Id
Expand All @@ -41,4 +46,7 @@ public TeamLike(final Long memberId, final Team team, final Boolean isLiked) {
this.isLiked = isLiked;
}

public void updateIsLiked(final Boolean isLiked) {
this.isLiked = isLiked;
}
}
Loading