diff --git a/README.md b/README.md index dcf2238e..8522c237 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,36 @@ +

+ 토덕 To.duck Logo +

+

토덕 To.duck

+

+ 성인 ADHD인을 위한 토닥임 +

+

+ + Download on the App Store + +

+

+ Copyright © 2025 To.duck Team +

+
+ +### 🔗 API +- [https://api-toduck.seol.pro](https://api-toduck.seol.pro/) + - [Swagger UI](https://api-toduck.seol.pro/swagger-ui/index.html) + - [에러 코드](https://api-toduck.seol.pro/exception-codes) + +
+ ### 🧑‍💻 Authors +| 강민기 | 박준하 | 설진영 | 최연우 | +|:----------------------------------------------------------------------------:|:----------------------------------------------------------------------------:|:----------------------------------------------------------------------------:|:----------------------------------------------------------------------------:| +| | | | | +| [@kang20](https://github.com/kang20) | [@Junad-Park](https://github.com/Junad-Park) | [@Seol-JY](https://github.com/Seol-JY) | [@wafla](https://github.com/wafla) | -| 강민기 | 박준하 | 설진영 | -|:----------------------------------------------------------------------------:|:----------------------------------------------------------------------------:|:----------------------------------------------------------------------------:| -| | | | -| [@kang20](https://github.com/kang20) | [@Junad-Park](https://github.com/Junad-Park) | [@Seol-JY](https://github.com/Seol-JY) | +
### 📜 Docs - - [초기 개발환경 설정](https://kyxxn.notion.site/fdf5a3a523f040328a9c68d4377ff997?pvs=74) - [개발 가이드](https://kyxxn.notion.site/9a2670768f784ea5bb3ca8f044ede895) - [API 개요](https://kyxxn.notion.site/API-e775e161efa6459583a0ee0d586c4d19) diff --git a/sql/init.sql b/sql/init.sql index 9fa6cbb8..39ec89f6 100644 --- a/sql/init.sql +++ b/sql/init.sql @@ -408,6 +408,57 @@ CREATE TABLE events_social FOREIGN KEY (user_id) REFERENCES users (id) ); +CREATE TABLE admin +( + id BIGINT PRIMARY KEY auto_increment, + user_id BIGINT NOT NULL UNIQUE, + display_name VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted_at DATETIME NULL, + FOREIGN KEY (user_id) REFERENCES users (id) +); + +CREATE TABLE inquiry +( + id BIGINT PRIMARY KEY auto_increment, + user_id BIGINT NOT NULL, + type VARCHAR(50) NOT NULL, + content VARCHAR(1024) NOT NULL, + status VARCHAR(50) NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted_at DATETIME NULL, + FOREIGN KEY (user_id) REFERENCES users (id) +); + +CREATE TABLE inquiry_image_file +( + id BIGINT PRIMARY KEY auto_increment, + inquiry_id BIGINT NOT NULL, + url VARCHAR(1024) NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted_at DATETIME NULL, + FOREIGN KEY (inquiry_id) REFERENCES inquiry (id) +); + +CREATE TABLE inquiry_answer +( + id BIGINT PRIMARY KEY auto_increment, + admin_id BIGINT NOT NULL, + inquiry_id BIGINT NOT NULL, + content VARCHAR(1024) NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted_at DATETIME NULL, + + CONSTRAINT uq_inquiry_answer UNIQUE (inquiry_id), + + FOREIGN KEY (admin_id) REFERENCES admin (id), + FOREIGN KEY (inquiry_id) REFERENCES inquiry (id) +); + CREATE TABLE account_deletion_log ( id BIGINT PRIMARY KEY auto_increment, diff --git a/src/main/java/im/toduck/domain/admin/common/mapper/AdminMapper.java b/src/main/java/im/toduck/domain/admin/common/mapper/AdminMapper.java new file mode 100644 index 00000000..8b1bcad8 --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/common/mapper/AdminMapper.java @@ -0,0 +1,24 @@ +package im.toduck.domain.admin.common.mapper; + +import java.util.List; + +import im.toduck.domain.admin.persistence.entity.Admin; +import im.toduck.domain.admin.presentation.dto.response.AdminListResponse; +import im.toduck.domain.admin.presentation.dto.response.AdminResponse; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class AdminMapper { + public static AdminResponse toAdminResponse(final Admin admin) { + return new AdminResponse( + admin.getId(), + admin.getUser().getId(), + admin.getDisplayName() + ); + } + + public static AdminListResponse toAdminListResponse(final List admins) { + return AdminListResponse.toListAdminResponse(admins); + } +} diff --git a/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java b/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java new file mode 100644 index 00000000..f4500eb0 --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/domain/service/AdminService.java @@ -0,0 +1,93 @@ +package im.toduck.domain.admin.domain.service; + +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import im.toduck.domain.admin.common.mapper.AdminMapper; +import im.toduck.domain.admin.persistence.entity.Admin; +import im.toduck.domain.admin.persistence.repository.AdminRepository; +import im.toduck.domain.admin.presentation.dto.request.AdminCreateRequest; +import im.toduck.domain.admin.presentation.dto.request.AdminUpdateRequest; +import im.toduck.domain.admin.presentation.dto.response.AdminResponse; +import im.toduck.domain.user.persistence.entity.User; +import im.toduck.domain.user.persistence.entity.UserRole; +import im.toduck.domain.user.persistence.repository.UserRepository; +import im.toduck.global.exception.CommonException; +import im.toduck.global.exception.ExceptionCode; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AdminService { + private final UserRepository userRepository; + private final AdminRepository adminRepository; + + @Transactional + public Admin getAdmin(final Long userId) { + return adminRepository.findActiveAdminByUserId(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_ADMIN)); + } + + // Role은 Admin이지만 admin 테이블에 등록되어있지 않은 경우 사용합니다. + @Transactional + public Admin getAdminBySameUser(final Long userId) { + return adminRepository.findActiveAdminByUserId(userId) + .orElseGet(() -> createDefaultAdmin(userId)); + } + + private Admin createDefaultAdmin(final Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + if (user.getRole() != UserRole.ADMIN) { + throw CommonException.from(ExceptionCode.NOT_FOUND_ADMIN); + } + + Admin admin = Admin.builder() + .user(user) + .displayName("토덕 관리자") + .build(); + + return adminRepository.save(admin); + } + + @Transactional(readOnly = true) + public List getAdmins() { + List admins = adminRepository.findAllActiveAdmins(); + return admins.stream() + .map(AdminMapper::toAdminResponse) + .toList(); + } + + @Transactional(readOnly = true) + public Optional getExistingAdmin(final Long userId) { + return adminRepository.findByUserIdIncludeDeleted(userId); + } + + @Transactional + public Admin createAdmin(final AdminCreateRequest request, final User user) { + Admin admin = Admin.builder() + .user(user) + .displayName(request.displayName()) + .build(); + + return adminRepository.save(admin); + } + + @Transactional + public void updateAdmin(final Long userId, final AdminUpdateRequest request) { + Admin admin = adminRepository.findActiveAdminByUserId(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_ADMIN)); + if (request.displayName() != null) { + admin.updateDisplayName(request.displayName()); + } + } + + @Transactional + public void deleteAdmin(final Admin admin) { + adminRepository.delete(admin); + } +} diff --git a/src/main/java/im/toduck/domain/admin/domain/usecase/AdminUseCase.java b/src/main/java/im/toduck/domain/admin/domain/usecase/AdminUseCase.java new file mode 100644 index 00000000..3b1c1e6d --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/domain/usecase/AdminUseCase.java @@ -0,0 +1,93 @@ +package im.toduck.domain.admin.domain.usecase; + +import java.util.List; + +import org.springframework.transaction.annotation.Transactional; + +import im.toduck.domain.admin.common.mapper.AdminMapper; +import im.toduck.domain.admin.domain.service.AdminService; +import im.toduck.domain.admin.persistence.entity.Admin; +import im.toduck.domain.admin.presentation.dto.request.AdminCreateRequest; +import im.toduck.domain.admin.presentation.dto.request.AdminUpdateRequest; +import im.toduck.domain.admin.presentation.dto.response.AdminListResponse; +import im.toduck.domain.admin.presentation.dto.response.AdminResponse; +import im.toduck.domain.user.domain.service.UserService; +import im.toduck.domain.user.persistence.entity.User; +import im.toduck.domain.user.persistence.entity.UserRole; +import im.toduck.global.annotation.UseCase; +import im.toduck.global.exception.CommonException; +import im.toduck.global.exception.ExceptionCode; +import lombok.RequiredArgsConstructor; + +@UseCase +@RequiredArgsConstructor +public class AdminUseCase { + private final AdminService adminService; + private final UserService userService; + + @Transactional + public AdminResponse getAdmin(final Long userId) { + Admin admin = adminService.getAdmin(userId); + + return AdminMapper.toAdminResponse(admin); + } + + @Transactional + public AdminListResponse getAdmins() { + List admins = adminService.getAdmins(); + + return AdminMapper.toAdminListResponse(admins); + } + + @Transactional + public Admin createAdmin(final AdminCreateRequest request) { + User user = userService.getUserById(request.userId()) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + Admin existingAdmin = adminService.getExistingAdmin(user.getId()).orElse(null); + + // 이미 활성화된 관리자인 경우 + if (existingAdmin != null && existingAdmin.getDeletedAt() == null) { + throw CommonException.from(ExceptionCode.DUPLICATE_ADMIN); + } + + // 삭제된 관리자 복구 + if (existingAdmin != null) { + existingAdmin.revive(); + existingAdmin.updateDisplayName(request.displayName()); + + if (user.getRole() == UserRole.USER) { + user.promoteToAdmin(); + } + + return existingAdmin; + } + + // 신규 관리자 + Admin admin = adminService.createAdmin(request, user); + + if (user.getRole() == UserRole.USER) { + user.promoteToAdmin(); + } + + return admin; + } + + @Transactional + public void updateAdmin(final Long userId, final AdminUpdateRequest request) { + User user = userService.getUserById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + adminService.updateAdmin(userId, request); + } + + @Transactional + public void deleteAdmin(final Long userId) { + Admin admin = adminService.getAdmin(userId); + + User user = admin.getUser(); + user.demoteToUser(); + + adminService.deleteAdmin(admin); + } +} diff --git a/src/main/java/im/toduck/domain/admin/persistence/entity/Admin.java b/src/main/java/im/toduck/domain/admin/persistence/entity/Admin.java new file mode 100644 index 00000000..ac0203e8 --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/persistence/entity/Admin.java @@ -0,0 +1,51 @@ +package im.toduck.domain.admin.persistence.entity; + +import org.hibernate.annotations.SQLDelete; + +import im.toduck.domain.user.persistence.entity.User; +import im.toduck.global.base.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "admin") +@Getter +@NoArgsConstructor +@SQLDelete(sql = "UPDATE admin SET deleted_at = NOW() where id=?") +public class Admin extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + + @Column(length = 255, nullable = false) + private String displayName; + + @Builder + private Admin(User user, + String displayName) { + this.user = user; + this.displayName = displayName; + } + + public void updateDisplayName(final String displayName) { + this.displayName = displayName; + } + + public void revive() { + this.deletedAt = null; + } +} diff --git a/src/main/java/im/toduck/domain/admin/persistence/repository/AdminRepository.java b/src/main/java/im/toduck/domain/admin/persistence/repository/AdminRepository.java new file mode 100644 index 00000000..513a1bcc --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/persistence/repository/AdminRepository.java @@ -0,0 +1,11 @@ +package im.toduck.domain.admin.persistence.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import im.toduck.domain.admin.persistence.entity.Admin; +import im.toduck.domain.admin.persistence.repository.querydsl.AdminRepositoryCustom; + +@Repository +public interface AdminRepository extends JpaRepository, AdminRepositoryCustom { +} diff --git a/src/main/java/im/toduck/domain/admin/persistence/repository/querydsl/AdminRepositoryCustom.java b/src/main/java/im/toduck/domain/admin/persistence/repository/querydsl/AdminRepositoryCustom.java new file mode 100644 index 00000000..fd96da3a --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/persistence/repository/querydsl/AdminRepositoryCustom.java @@ -0,0 +1,14 @@ +package im.toduck.domain.admin.persistence.repository.querydsl; + +import java.util.List; +import java.util.Optional; + +import im.toduck.domain.admin.persistence.entity.Admin; + +public interface AdminRepositoryCustom { + Optional findActiveAdminByUserId(Long userId); + + List findAllActiveAdmins(); + + Optional findByUserIdIncludeDeleted(Long userId); +} diff --git a/src/main/java/im/toduck/domain/admin/persistence/repository/querydsl/AdminRepositoryCustomImpl.java b/src/main/java/im/toduck/domain/admin/persistence/repository/querydsl/AdminRepositoryCustomImpl.java new file mode 100644 index 00000000..39bf181f --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/persistence/repository/querydsl/AdminRepositoryCustomImpl.java @@ -0,0 +1,53 @@ +package im.toduck.domain.admin.persistence.repository.querydsl; + +import java.util.List; +import java.util.Optional; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import im.toduck.domain.admin.persistence.entity.Admin; +import im.toduck.domain.admin.persistence.entity.QAdmin; +import im.toduck.domain.user.persistence.entity.QUser; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class AdminRepositoryCustomImpl implements AdminRepositoryCustom { + + private final JPAQueryFactory queryFactory; + private final QAdmin admin = QAdmin.admin; + private final QUser user = QUser.user; + + @Override + public Optional findActiveAdminByUserId(final Long userId) { + Admin result = queryFactory + .selectFrom(admin) + .join(admin.user, user).fetchJoin() + .where( + user.id.eq(userId), + admin.deletedAt.isNull() + ) + .fetchOne(); + + return Optional.ofNullable(result); + } + + @Override + public List findAllActiveAdmins() { + return queryFactory + .selectFrom(admin) + .where(admin.deletedAt.isNull()) + .fetch(); + } + + @Override + public Optional findByUserIdIncludeDeleted(final Long userId) { + Admin result = queryFactory + .selectFrom(admin) + .where( + admin.user.id.eq(userId) + ) + .fetchOne(); + + return Optional.ofNullable(result); + } +} diff --git a/src/main/java/im/toduck/domain/admin/presentation/api/AdminApi.java b/src/main/java/im/toduck/domain/admin/presentation/api/AdminApi.java new file mode 100644 index 00000000..140dae08 --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/presentation/api/AdminApi.java @@ -0,0 +1,129 @@ +package im.toduck.domain.admin.presentation.api; + +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import im.toduck.domain.admin.presentation.dto.request.AdminCreateRequest; +import im.toduck.domain.admin.presentation.dto.request.AdminUpdateRequest; +import im.toduck.domain.admin.presentation.dto.response.AdminListResponse; +import im.toduck.domain.admin.presentation.dto.response.AdminResponse; +import im.toduck.global.annotation.swagger.ApiErrorResponseExplanation; +import im.toduck.global.annotation.swagger.ApiResponseExplanations; +import im.toduck.global.annotation.swagger.ApiSuccessResponseExplanation; +import im.toduck.global.exception.ExceptionCode; +import im.toduck.global.presentation.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Admin") +public interface AdminApi { + @Operation( + summary = "관리자 조회", + description = "관리자를 조회합니다. 관리자 권한을 가진 사용자만 요청할 수 있습니다." + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + responseClass = AdminResponse.class, + description = "관리자 조회 성공. 관리자 데이터를 반환합니다." + ), + errors = { + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_ADMIN) + } + ) + ResponseEntity> getAdmin( + @PathVariable final Long userId + ); + + @Operation( + summary = "관리자 목록 조회", + description = "관리자 목록을 조회합니다. 관리자 권한을 가진 사용자만 요청할 수 있습니다." + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + responseClass = AdminListResponse.class, + description = "관리자 목록 조회 성공. 관리자 목록 데이터를 반환합니다." + ) + ) + ResponseEntity> getAdmins(); + + @Operation( + summary = "관리자 생성", + description = """ + 관리자를 생성합니다. 관리자 권한을 가진 사용자만 요청할 수 있습니다.
+ "userId": 관리자에 등록할 사용자 ID를 입력합니다. Role이 User에서 Admin으로 승격됩니다.
+ "displayName": 문의 답변 등에서 사용자들에게 보여질 이름입니다.
+ """ + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + description = "관리자 생성 성공. 빈 content 객체를 반환합니다." + ), + errors = { + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_USER), + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.DUPLICATE_ADMIN) + } + ) + ResponseEntity>> createAdmin( + @RequestBody final AdminCreateRequest request + ); + + @Operation( + summary = "관리자 정보 수정", + description = """ + 관리자 정보를 수정합니다. 관리자 권한을 가진 사용자만 요청할 수 있습니다.
+ Path Variable에는 관리자의 사용자 ID를 입력합니다.
+
+ "displayName": 문의 답변 등에서 사용자들에게 보여질 이름입니다. null인 경우 수정되지 않습니다.
+ """ + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + description = "관리자 정보 수정 성공. 빈 content 객체를 반환합니다." + ), + errors = { + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_USER), + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_ADMIN) + } + ) + ResponseEntity>> updateAdmin( + @Parameter( + name = "userId", + description = "수정할 관리자의 사용자 ID", + required = true, + example = "1" + ) + @PathVariable final Long userId, + @RequestBody final AdminUpdateRequest request + ); + + @Operation( + summary = "관리자 정보 삭제", + description = """ + 관리자 정보를 삭제합니다. 관리자 권한을 가진 사용자만 요청할 수 있습니다.
+ Path Variable에는 관리자의 사용자 ID를 입력합니다.
+ 해당 사용자의 관리자 정보가 논리 삭제되며 관리자 권한 또한 제거됩니다.
+ """ + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + description = "관리자 정보 삭제 성공. 빈 content 객체를 반환합니다." + ), + errors = { + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_USER), + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_ADMIN) + } + ) + ResponseEntity>> deleteAdmin( + @Parameter( + name = "userId", + description = "삭제할 관리자의 사용자 ID", + required = true, + example = "1" + ) + @PathVariable final Long userId + ); +} diff --git a/src/main/java/im/toduck/domain/admin/presentation/controller/AdminController.java b/src/main/java/im/toduck/domain/admin/presentation/controller/AdminController.java new file mode 100644 index 00000000..a1e99e65 --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/presentation/controller/AdminController.java @@ -0,0 +1,86 @@ +package im.toduck.domain.admin.presentation.controller; + +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import im.toduck.domain.admin.domain.usecase.AdminUseCase; +import im.toduck.domain.admin.presentation.api.AdminApi; +import im.toduck.domain.admin.presentation.dto.request.AdminCreateRequest; +import im.toduck.domain.admin.presentation.dto.request.AdminUpdateRequest; +import im.toduck.domain.admin.presentation.dto.response.AdminListResponse; +import im.toduck.domain.admin.presentation.dto.response.AdminResponse; +import im.toduck.global.presentation.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/admin") +public class AdminController implements AdminApi { + + private final AdminUseCase adminUseCase; + + @Override + @GetMapping("/{userId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> getAdmin( + @PathVariable final Long userId + ) { + AdminResponse response = adminUseCase.getAdmin(userId); + + return ResponseEntity.ok(ApiResponse.createSuccess(response)); + } + + @Override + @GetMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> getAdmins() { + AdminListResponse response = adminUseCase.getAdmins(); + + return ResponseEntity.ok(ApiResponse.createSuccess(response)); + } + + @Override + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity>> createAdmin( + @RequestBody @Valid final AdminCreateRequest request + ) { + adminUseCase.createAdmin(request); + + return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); + } + + @Override + @PatchMapping("/{userId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity>> updateAdmin( + @PathVariable final Long userId, + @RequestBody @Valid final AdminUpdateRequest request + ) { + adminUseCase.updateAdmin(userId, request); + + return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); + } + + @Override + @DeleteMapping("/{userId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity>> deleteAdmin( + @PathVariable final Long userId + ) { + adminUseCase.deleteAdmin(userId); + + return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); + } +} diff --git a/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminCreateRequest.java b/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminCreateRequest.java new file mode 100644 index 00000000..e382b87c --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminCreateRequest.java @@ -0,0 +1,21 @@ +package im.toduck.domain.admin.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +@Schema(description = "관리자 생성 요청 DTO") +public record AdminCreateRequest( + @NotNull(message = "사용자 ID는 비어있을 수 없습니다.") + @Schema(description = "사용자 ID", example = "1") + Long userId, + + @NotBlank(message = "관리자 표시명은 비어있을 수 없습니다.") + @Size(max = 255, message = "관리자 표시명은 255자를 초과할 수 없습니다.") + @Schema(description = "관리자 표시명", example = "토덕 관리자") + String displayName +) { +} diff --git a/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminUpdateRequest.java b/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminUpdateRequest.java new file mode 100644 index 00000000..e2b5491a --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/presentation/dto/request/AdminUpdateRequest.java @@ -0,0 +1,16 @@ +package im.toduck.domain.admin.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +@Schema(description = "관리자 수정 요청 DTO") +public record AdminUpdateRequest( + @NotBlank(message = "관리자 표시명은 비어있을 수 없습니다.") + @Size(max = 255, message = "관리자 표시명은 255자를 초과할 수 없습니다.") + @Schema(description = "관리자 표시명", example = "토덕 관리자") + String displayName +) { +} diff --git a/src/main/java/im/toduck/domain/admin/presentation/dto/response/AdminListResponse.java b/src/main/java/im/toduck/domain/admin/presentation/dto/response/AdminListResponse.java new file mode 100644 index 00000000..53e6c566 --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/presentation/dto/response/AdminListResponse.java @@ -0,0 +1,21 @@ +package im.toduck.domain.admin.presentation.dto.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "관리자 목록 응답") +public record AdminListResponse( + @Schema(description = "관리자 목록") + List adminDtos +) { + public static AdminListResponse toListAdminResponse( + final List admins + ) { + return AdminListResponse.builder() + .adminDtos(admins) + .build(); + } +} diff --git a/src/main/java/im/toduck/domain/admin/presentation/dto/response/AdminResponse.java b/src/main/java/im/toduck/domain/admin/presentation/dto/response/AdminResponse.java new file mode 100644 index 00000000..7ae769ce --- /dev/null +++ b/src/main/java/im/toduck/domain/admin/presentation/dto/response/AdminResponse.java @@ -0,0 +1,18 @@ +package im.toduck.domain.admin.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "관리자 응답") +public record AdminResponse( + @Schema(description = "관리자 ID", example = "1") + Long adminId, + + @Schema(description = "사용자 ID", example = "1") + Long userId, + + @Schema(description = "관리자 표시명", example = "토덕 관리자") + String displayName +) { +} diff --git a/src/main/java/im/toduck/domain/diary/domain/service/DiaryService.java b/src/main/java/im/toduck/domain/diary/domain/service/DiaryService.java index bd81d514..8aa4d5b1 100644 --- a/src/main/java/im/toduck/domain/diary/domain/service/DiaryService.java +++ b/src/main/java/im/toduck/domain/diary/domain/service/DiaryService.java @@ -62,9 +62,6 @@ public Optional getDiaryById(final Long diaryId) { @Transactional public void deleteDiary(final Diary diary) { - List imageFiles = diaryImageRepository.findAllByDiary(diary); - imageFiles.forEach(DiaryImage::softDelete); - diaryRepository.delete(diary); } diff --git a/src/main/java/im/toduck/domain/diary/domain/usecase/DiaryUseCase.java b/src/main/java/im/toduck/domain/diary/domain/usecase/DiaryUseCase.java index 59b1ba23..c0d6a1d9 100644 --- a/src/main/java/im/toduck/domain/diary/domain/usecase/DiaryUseCase.java +++ b/src/main/java/im/toduck/domain/diary/domain/usecase/DiaryUseCase.java @@ -8,6 +8,7 @@ import im.toduck.domain.diary.common.mapper.DiaryMapper; import im.toduck.domain.diary.domain.service.DiaryService; import im.toduck.domain.diary.persistence.entity.Diary; +import im.toduck.domain.diary.persistence.entity.DiaryImage; import im.toduck.domain.diary.presentation.dto.request.DiaryCreateRequest; import im.toduck.domain.diary.presentation.dto.request.DiaryUpdateRequest; import im.toduck.domain.diary.presentation.dto.response.DiaryListResponse; @@ -55,6 +56,8 @@ public void deleteDiary(Long userId, Long diaryId) { throw CommonException.from(ExceptionCode.UNAUTHORIZED_ACCESS_DIARY); } + diary.getDiaryImages().forEach(DiaryImage::softDelete); + diaryService.deleteDiary(diary); log.info("일기 삭제 - UserId: {}, DiaryId: {}", userId, diaryId); } diff --git a/src/main/java/im/toduck/domain/diary/persistence/entity/Diary.java b/src/main/java/im/toduck/domain/diary/persistence/entity/Diary.java index b43432a6..14b31dfc 100644 --- a/src/main/java/im/toduck/domain/diary/persistence/entity/Diary.java +++ b/src/main/java/im/toduck/domain/diary/persistence/entity/Diary.java @@ -56,7 +56,7 @@ public class Diary extends BaseEntity { @Lob private String memo; - @OneToMany(mappedBy = "diary", cascade = CascadeType.ALL, orphanRemoval = true) + @OneToMany(mappedBy = "diary", cascade = CascadeType.ALL) private List diaryImages = new ArrayList<>(); @OneToMany(mappedBy = "diary", cascade = CascadeType.ALL, orphanRemoval = true) diff --git a/src/main/java/im/toduck/domain/diary/persistence/entity/DiaryImage.java b/src/main/java/im/toduck/domain/diary/persistence/entity/DiaryImage.java index 05c897f0..df2c27d6 100644 --- a/src/main/java/im/toduck/domain/diary/persistence/entity/DiaryImage.java +++ b/src/main/java/im/toduck/domain/diary/persistence/entity/DiaryImage.java @@ -2,9 +2,6 @@ import java.time.LocalDateTime; -import org.hibernate.annotations.SQLDelete; -import org.hibernate.annotations.SQLRestriction; - import im.toduck.global.base.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -22,8 +19,6 @@ @Table(name = "diary_image_file") @Getter @NoArgsConstructor -@SQLDelete(sql = "UPDATE diary_image_file SET deleted_at = NOW() where id=?") -@SQLRestriction(value = "deleted_at is NULL") public class DiaryImage extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/im/toduck/domain/events/detail/domain/service/EventsDetailService.java b/src/main/java/im/toduck/domain/events/detail/domain/service/EventsDetailService.java index eb2b41b9..0f516609 100644 --- a/src/main/java/im/toduck/domain/events/detail/domain/service/EventsDetailService.java +++ b/src/main/java/im/toduck/domain/events/detail/domain/service/EventsDetailService.java @@ -43,7 +43,7 @@ public EventsDetail createEventsDetail(final EventsDetailCreateRequest request) } @Transactional - public void addEventsDetailImges(final EventsDetail eventsDetail, final List imgUrls) { + public void addEventsDetailImages(final EventsDetail eventsDetail, final List imgUrls) { List safeImgs = Optional.ofNullable(imgUrls).orElse(Collections.emptyList()); List eventsDetailImgs = safeImgs.stream() @@ -95,7 +95,7 @@ public void updateEventsDetail( if (request.eventsDetailImgs() != null && !request.eventsDetailImgs().isEmpty()) { eventsDetailImgRepository.deleteAllByEventsDetail(eventsDetail); - addEventsDetailImges(eventsDetail, request.eventsDetailImgs()); + addEventsDetailImages(eventsDetail, request.eventsDetailImgs()); } else { eventsDetailImgRepository.deleteAllByEventsDetail(eventsDetail); } diff --git a/src/main/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCase.java b/src/main/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCase.java index 7a837186..fb0b829d 100644 --- a/src/main/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCase.java +++ b/src/main/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCase.java @@ -7,6 +7,7 @@ import im.toduck.domain.events.detail.common.mapper.EventsDetailMapper; import im.toduck.domain.events.detail.domain.service.EventsDetailService; import im.toduck.domain.events.detail.persistence.entity.EventsDetail; +import im.toduck.domain.events.detail.persistence.entity.EventsDetailImg; import im.toduck.domain.events.detail.presentation.dto.request.EventsDetailCreateRequest; import im.toduck.domain.events.detail.presentation.dto.request.EventsDetailUpdateRequest; import im.toduck.domain.events.detail.presentation.dto.response.EventsDetailListResponse; @@ -47,7 +48,7 @@ public EventsDetail createEventsDetail(final EventsDetailCreateRequest request, } EventsDetail eventsDetail = eventsDetailService.createEventsDetail(request); - eventsDetailService.addEventsDetailImges(eventsDetail, request.eventsDetailImgs()); + eventsDetailService.addEventsDetailImages(eventsDetail, request.eventsDetailImgs()); return eventsDetail; } @@ -76,6 +77,8 @@ public void deleteEventsDetail(final Long eventsDetailId, final Long userId) { EventsDetail eventsDetail = eventsDetailService.findById(eventsDetailId) .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_EVENTS_DETAIL)); + eventsDetail.getEventsDetailImgs().forEach(EventsDetailImg::softDelete); + eventsDetailService.deleteEventsDetail(eventsDetail); } } diff --git a/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetail.java b/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetail.java index bd28f115..2a00d789 100644 --- a/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetail.java +++ b/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetail.java @@ -47,7 +47,7 @@ public class EventsDetail extends BaseEntity { @Column(length = 63) private String buttonText; - @OneToMany(mappedBy = "eventsDetail", cascade = CascadeType.ALL, orphanRemoval = true) + @OneToMany(mappedBy = "eventsDetail", cascade = CascadeType.ALL) private List eventsDetailImgs = new ArrayList<>(); @Builder diff --git a/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetailImg.java b/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetailImg.java index 19f30f01..6d9b2ce2 100644 --- a/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetailImg.java +++ b/src/main/java/im/toduck/domain/events/detail/persistence/entity/EventsDetailImg.java @@ -1,11 +1,11 @@ package im.toduck.domain.events.detail.persistence.entity; -import org.hibernate.annotations.SQLDelete; -import org.hibernate.annotations.SQLRestriction; +import java.time.LocalDateTime; import im.toduck.global.base.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -20,14 +20,12 @@ @Table(name = "events_detail_img") @Getter @NoArgsConstructor -@SQLDelete(sql = "UPDATE events_detail_img SET deleted_at = NOW() where id=?") -@SQLRestriction(value = "deleted_at is NULL") public class EventsDetailImg extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "events_detail_id", nullable = false) private EventsDetail eventsDetail; @@ -47,4 +45,8 @@ public void updateEventsDetail(final EventsDetail eventsDetail) { public void updateDetailImgUrl(final String detailImgUrl) { this.detailImgUrl = detailImgUrl; } + + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/im/toduck/domain/events/detail/presentation/dto/request/EventsDetailCreateRequest.java b/src/main/java/im/toduck/domain/events/detail/presentation/dto/request/EventsDetailCreateRequest.java index 7a27786f..8b07a5e6 100644 --- a/src/main/java/im/toduck/domain/events/detail/presentation/dto/request/EventsDetailCreateRequest.java +++ b/src/main/java/im/toduck/domain/events/detail/presentation/dto/request/EventsDetailCreateRequest.java @@ -26,7 +26,7 @@ public record EventsDetailCreateRequest( @Schema(description = "버튼 내용", example = "당첨 확인하기") String buttonText, - @Schema(description = "이벤트 디테일 URL 목록", example = "[\"https://cdn.toduck.app/image1.jpg\"]") + @Schema(description = "이벤트 디테일 사진 URL 목록", example = "[\"https://cdn.toduck.app/image1.jpg\"]") List eventsDetailImgs ) { diff --git a/src/main/java/im/toduck/domain/inquiry/common/mapper/InquiryImgMapper.java b/src/main/java/im/toduck/domain/inquiry/common/mapper/InquiryImgMapper.java new file mode 100644 index 00000000..3b51a794 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/common/mapper/InquiryImgMapper.java @@ -0,0 +1,20 @@ +package im.toduck.domain.inquiry.common.mapper; + +import im.toduck.domain.inquiry.persistence.entity.Inquiry; +import im.toduck.domain.inquiry.persistence.entity.InquiryImage; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class InquiryImgMapper { + public static InquiryImage toInquiryImg(Inquiry inquiry, String imgUrls) { + return InquiryImage.builder() + .inquiry(inquiry) + .url(imgUrls) + .build(); + } + + public static String fromInquiryImg(InquiryImage inquiryImage) { + return inquiryImage.getUrl(); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/common/mapper/InquiryMapper.java b/src/main/java/im/toduck/domain/inquiry/common/mapper/InquiryMapper.java new file mode 100644 index 00000000..038c23e7 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/common/mapper/InquiryMapper.java @@ -0,0 +1,51 @@ +package im.toduck.domain.inquiry.common.mapper; + +import java.util.List; + +import im.toduck.domain.inquiry.persistence.entity.Inquiry; +import im.toduck.domain.inquiry.persistence.entity.InquiryAnswer; +import im.toduck.domain.inquiry.persistence.entity.Status; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.response.InquiryListResponse; +import im.toduck.domain.inquiry.presentation.dto.response.InquiryResponse; +import im.toduck.domain.user.persistence.entity.User; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class InquiryMapper { + + public static InquiryResponse fromInquiry(final Inquiry inquiry) { + + InquiryAnswer answer = inquiry.getInquiryAnswer(); + + return new InquiryResponse( + inquiry.getId(), + inquiry.getType(), + inquiry.getContent(), + inquiry.getStatus(), + inquiry.getCreatedAt(), + inquiry.getInquiryImages().stream() + .map(InquiryImgMapper::fromInquiryImg) + .toList(), + + answer != null ? answer.getId() : null, + answer != null ? answer.getAdmin().getDisplayName() : null, + answer != null ? answer.getContent() : null, + answer != null ? answer.getCreatedAt() : null + ); + } + + public static InquiryListResponse toListInquiryResponse(final List inquiries) { + return InquiryListResponse.toListInquiryResponse(inquiries); + } + + public static Inquiry toInquiry(final InquiryCreateRequest request, final User user) { + return Inquiry.builder() + .user(user) + .type(request.type()) + .content(request.content()) + .status(Status.PENDING) + .build(); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryAnswerService.java b/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryAnswerService.java new file mode 100644 index 00000000..65155333 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryAnswerService.java @@ -0,0 +1,93 @@ +package im.toduck.domain.inquiry.domain.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import im.toduck.domain.admin.persistence.entity.Admin; +import im.toduck.domain.inquiry.persistence.entity.Inquiry; +import im.toduck.domain.inquiry.persistence.entity.InquiryAnswer; +import im.toduck.domain.inquiry.persistence.entity.Status; +import im.toduck.domain.inquiry.persistence.repository.InquiryAnswerRepository; +import im.toduck.domain.inquiry.persistence.repository.InquiryRepository; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerUpdateRequest; +import im.toduck.global.exception.CommonException; +import im.toduck.global.exception.ExceptionCode; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class InquiryAnswerService { + private final InquiryRepository inquiryRepository; + private final InquiryAnswerRepository inquiryAnswerRepository; + + @Transactional + public Optional findById(final Long inquiryAnswerId) { + return inquiryAnswerRepository.findById(inquiryAnswerId); + } + + @Transactional + public InquiryAnswer createInquiryAnswer(final InquiryAnswerCreateRequest request, final Admin admin) { + Inquiry inquiry = inquiryRepository.findById(request.inquiryId()) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY)); + + Optional anyAnswerOpt = + inquiryAnswerRepository.findAnyByInquiryIdIncludingDeleted(inquiry.getId()); + + if (anyAnswerOpt.isPresent()) { + InquiryAnswer existing = anyAnswerOpt.get(); + + if (existing.getDeletedAt() == null) { + throw CommonException.from(ExceptionCode.ALREADY_ANSWERED_INQUIRY); + } + + existing.revive(request.content(), admin); + existing.setInquiry(inquiry); + inquiry.addAnswer(existing); + inquiry.changeStatus(Status.ANSWERED); + return inquiryAnswerRepository.save(existing); + } + + InquiryAnswer newAnswer = InquiryAnswer.builder() + .admin(admin) + .inquiry(inquiry) + .content(request.content()) + .build(); + + inquiry.addAnswer(newAnswer); + inquiry.changeStatus(Status.ANSWERED); + + return newAnswer; + } + + @Transactional + public InquiryAnswer updateInquiryAnswer( + final Long inquiryId, + final InquiryAnswerUpdateRequest request, + final Admin admin) { + Inquiry inquiry = inquiryRepository.findById(inquiryId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY)); + + InquiryAnswer inquiryAnswer = inquiry.getInquiryAnswer(); + inquiryAnswer.updateAnswer(request.content(), admin); + return inquiryAnswer; + } + + @Transactional + public void deleteInquiryAnswer(final Long inquiryId) { + Inquiry inquiry = inquiryRepository.findById(inquiryId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY)); + + InquiryAnswer inquiryAnswer = inquiry.getInquiryAnswer(); + if (inquiryAnswer == null) { + throw CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY_ANSWER); + } + + inquiry.changeStatus(Status.PENDING); + inquiry.removeAnswer(); + + inquiryAnswerRepository.delete(inquiryAnswer); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryService.java b/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryService.java new file mode 100644 index 00000000..05ac4c06 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/domain/service/InquiryService.java @@ -0,0 +1,91 @@ +package im.toduck.domain.inquiry.domain.service; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import im.toduck.domain.inquiry.common.mapper.InquiryImgMapper; +import im.toduck.domain.inquiry.common.mapper.InquiryMapper; +import im.toduck.domain.inquiry.persistence.entity.Inquiry; +import im.toduck.domain.inquiry.persistence.entity.InquiryImage; +import im.toduck.domain.inquiry.persistence.repository.InquiryImgRepository; +import im.toduck.domain.inquiry.persistence.repository.InquiryRepository; +import im.toduck.domain.inquiry.persistence.repository.querydsl.InquiryRepositoryCustom; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryUpdateRequest; +import im.toduck.domain.inquiry.presentation.dto.response.InquiryResponse; +import im.toduck.domain.user.persistence.entity.User; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class InquiryService { + private final InquiryRepository inquiryRepository; + private final InquiryImgRepository inquiryImgRepository; + private final InquiryRepositoryCustom inquiryRepositoryCustom; + + @Transactional(readOnly = true) + public List getInquiries(final Long userId) { + List inquiries = inquiryRepositoryCustom.findWithImgs(userId); + return inquiries.stream() + .map(InquiryMapper::fromInquiry) + .toList(); + } + + @Transactional + public Inquiry createInquiry(final InquiryCreateRequest request, final User user) { + Inquiry inquiry = InquiryMapper.toInquiry(request, user); + inquiryRepository.save(inquiry); + return inquiry; + } + + @Transactional + public void addInquiryImages(final Inquiry inquiry, final List imgUrls) { + List safeImgs = Optional.ofNullable(imgUrls).orElse(Collections.emptyList()); + + List inquiryImgs = safeImgs.stream() + .map(url -> InquiryImgMapper.toInquiryImg(inquiry, url)) + .toList(); + inquiryImgRepository.saveAll(inquiryImgs); + } + + @Transactional + public Optional getInquiryById(final Long inquiryId) { + return inquiryRepository.findById(inquiryId); + } + + @Transactional + public void updateInquiry(final InquiryUpdateRequest request, final Inquiry inquiry) { + if (request.type() != null) { + inquiry.updateType(request.type()); + } + + if (request.content() != null) { + inquiry.updateContent(request.content()); + } + + if (request.inquiryImgs() != null) { + List images = inquiryImgRepository.findAllByInquiry(inquiry); + images.forEach(inquiryImgRepository::delete); + if (!request.inquiryImgs().isEmpty()) { + addInquiryImages(inquiry, request.inquiryImgs()); + } + } + } + + @Transactional + public void deleteInquiry(final Inquiry inquiry) { + inquiryRepository.delete(inquiry); + } + + @Transactional(readOnly = true) + public List getAllInquiries() { + List inquiries = inquiryRepositoryCustom.findAllWithImgs(); + return inquiries.stream() + .map(InquiryMapper::fromInquiry) + .toList(); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryAnswerUseCase.java b/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryAnswerUseCase.java new file mode 100644 index 00000000..620657e8 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryAnswerUseCase.java @@ -0,0 +1,52 @@ +package im.toduck.domain.inquiry.domain.usecase; + +import org.springframework.transaction.annotation.Transactional; + +import im.toduck.domain.admin.domain.service.AdminService; +import im.toduck.domain.admin.persistence.entity.Admin; +import im.toduck.domain.inquiry.domain.service.InquiryAnswerService; +import im.toduck.domain.inquiry.persistence.entity.InquiryAnswer; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerUpdateRequest; +import im.toduck.domain.user.domain.service.UserService; +import im.toduck.domain.user.persistence.entity.User; +import im.toduck.global.annotation.UseCase; +import im.toduck.global.exception.CommonException; +import im.toduck.global.exception.ExceptionCode; +import lombok.RequiredArgsConstructor; + +@UseCase +@RequiredArgsConstructor +public class InquiryAnswerUseCase { + private final UserService userService; + private final AdminService adminService; + private final InquiryAnswerService inquiryAnswerService; + + @Transactional + public InquiryAnswer createInquiryAnswer(final InquiryAnswerCreateRequest request, final Long userId) { + User user = userService.getUserById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + Admin admin = adminService.getAdminBySameUser(user.getId()); + + return inquiryAnswerService.createInquiryAnswer(request, admin); + } + + @Transactional + public InquiryAnswer updateInquiryAnswer( + final Long inquiryId, + final InquiryAnswerUpdateRequest request, + final Long userId) { + User user = userService.getUserById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + Admin admin = adminService.getAdminBySameUser(userId); + + return inquiryAnswerService.updateInquiryAnswer(inquiryId, request, admin); + } + + @Transactional + public void deleteInquiryAnswer(final Long inquiryId) { + inquiryAnswerService.deleteInquiryAnswer(inquiryId); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryUseCase.java b/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryUseCase.java new file mode 100644 index 00000000..0738ab56 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/domain/usecase/InquiryUseCase.java @@ -0,0 +1,102 @@ +package im.toduck.domain.inquiry.domain.usecase; + +import java.util.List; + +import org.springframework.transaction.annotation.Transactional; + +import im.toduck.domain.inquiry.common.mapper.InquiryMapper; +import im.toduck.domain.inquiry.domain.service.InquiryService; +import im.toduck.domain.inquiry.persistence.entity.Inquiry; +import im.toduck.domain.inquiry.persistence.entity.InquiryAnswer; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryUpdateRequest; +import im.toduck.domain.inquiry.presentation.dto.response.InquiryListResponse; +import im.toduck.domain.inquiry.presentation.dto.response.InquiryResponse; +import im.toduck.domain.user.domain.service.UserService; +import im.toduck.domain.user.persistence.entity.User; +import im.toduck.global.annotation.UseCase; +import im.toduck.global.exception.CommonException; +import im.toduck.global.exception.ExceptionCode; +import lombok.RequiredArgsConstructor; + +@UseCase +@RequiredArgsConstructor +public class InquiryUseCase { + private final UserService userService; + private final InquiryService inquiryService; + + @Transactional(readOnly = true) + public InquiryListResponse getInquiries(Long userId) { + User user = userService.getUserById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + List inquiries = inquiryService.getInquiries(userId); + + return InquiryMapper.toListInquiryResponse(inquiries); + } + + @Transactional + public Inquiry createInquiry(final InquiryCreateRequest request, final Long userId) { + User user = userService.getUserById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + Inquiry inquiry = inquiryService.createInquiry(request, user); + inquiryService.addInquiryImages(inquiry, request.inquiryImgs()); + + return inquiry; + } + + @Transactional + public Inquiry updateInquiry( + final Long inquiryId, + final InquiryUpdateRequest request, + final Long userId) { + User user = userService.getUserById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + Inquiry inquiry = inquiryService.getInquiryById(inquiryId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY)); + + if (!inquiry.getUser().getId().equals(userId)) { + throw CommonException.from(ExceptionCode.UNAUTHORIZED_ACCESS_INQUIRY); + } + + InquiryAnswer answer = inquiry.getInquiryAnswer(); + if (answer != null && answer.getDeletedAt() == null) { + throw CommonException.from(ExceptionCode.ALREADY_ANSWERED_INQUIRY); + } + + inquiryService.updateInquiry(request, inquiry); + return inquiry; + } + + @Transactional + public void deleteInquiry(final Long inquiryId, final Long userId) { + User user = userService.getUserById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + Inquiry inquiry = inquiryService.getInquiryById(inquiryId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY)); + + if (!inquiry.getUser().getId().equals(userId)) { + throw CommonException.from(ExceptionCode.UNAUTHORIZED_ACCESS_INQUIRY); + } + + InquiryAnswer answer = inquiry.getInquiryAnswer(); + if (answer != null && answer.getDeletedAt() == null) { + throw CommonException.from(ExceptionCode.ALREADY_ANSWERED_INQUIRY); + } + + inquiryService.deleteInquiry(inquiry); + } + + @Transactional(readOnly = true) + public InquiryListResponse getAllInquiries(final Long userId) { + User user = userService.getUserById(userId) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); + + List inquiries = inquiryService.getAllInquiries(); + + return InquiryMapper.toListInquiryResponse(inquiries); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/entity/Inquiry.java b/src/main/java/im/toduck/domain/inquiry/persistence/entity/Inquiry.java new file mode 100644 index 00000000..d8c1410d --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/persistence/entity/Inquiry.java @@ -0,0 +1,95 @@ +package im.toduck.domain.inquiry.persistence.entity; + +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import im.toduck.domain.user.persistence.entity.User; +import im.toduck.global.base.entity.BaseEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "inquiry") +@Getter +@NoArgsConstructor +@SQLDelete(sql = "UPDATE inquiry SET deleted_at = NOW() where id=?") +@SQLRestriction(value = "deleted_at is NULL") +public class Inquiry extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Type type; + + @Column(length = 1024, nullable = false) + private String content; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Status status; + + @OneToMany(mappedBy = "inquiry", cascade = CascadeType.ALL) + private List inquiryImages = new ArrayList<>(); + + @OneToOne(mappedBy = "inquiry", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private InquiryAnswer inquiryAnswer; + + @Builder + private Inquiry(User user, + Type type, + String content, + Status status) { + this.user = user; + this.type = type; + this.content = content; + this.status = status; + } + + public void updateType(Type type) { + this.type = type; + } + + public void updateContent(String content) { + this.content = content; + } + + public void changeStatus(final Status status) { + this.status = status; + } + + public void addAnswer(final InquiryAnswer answer) { + this.inquiryAnswer = answer; + answer.setInquiry(this); + } + + public void removeAnswer() { + if (this.inquiryAnswer != null) { + this.inquiryAnswer.delete(); + this.inquiryAnswer = null; + } + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryAnswer.java b/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryAnswer.java new file mode 100644 index 00000000..5fadf2b7 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryAnswer.java @@ -0,0 +1,69 @@ +package im.toduck.domain.inquiry.persistence.entity; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import im.toduck.domain.admin.persistence.entity.Admin; +import im.toduck.global.base.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "inquiry_answer") +@Getter +@NoArgsConstructor +@SQLDelete(sql = "UPDATE inquiry_answer SET deleted_at = NOW() where id=?") +@SQLRestriction(value = "deleted_at is NULL") +public class InquiryAnswer extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "admin_id", nullable = false) + private Admin admin; + + @Setter + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "inquiry_id", nullable = false, unique = true) + private Inquiry inquiry; + + @Column(length = 1024, nullable = false) + private String content; + + @Builder + private InquiryAnswer(Admin admin, Inquiry inquiry, String content) { + this.admin = admin; + this.inquiry = inquiry; + this.content = content; + } + + public void updateAnswer(final String content, final Admin admin) { + this.content = content; + this.admin = admin; + } + + public void revive(final String content, final Admin admin) { + this.deletedAt = null; + this.content = content; + this.admin = admin; + } + + public void delete() { + this.deletedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryImage.java b/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryImage.java new file mode 100644 index 00000000..10fef170 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/persistence/entity/InquiryImage.java @@ -0,0 +1,49 @@ +package im.toduck.domain.inquiry.persistence.entity; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import im.toduck.global.base.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "inquiry_image_file") +@Getter +@NoArgsConstructor +@SQLDelete(sql = "UPDATE inquiry_image_file SET deleted_at = NOW() where id=?") +@SQLRestriction(value = "deleted_at is NULL") +public class InquiryImage extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "inquiry_id", nullable = false) + private Inquiry inquiry; + + @Column(name = "url", length = 1024, nullable = false) + private String url; + + @Builder + private InquiryImage(Inquiry inquiry, String url) { + this.inquiry = inquiry; + this.url = url; + } + + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/entity/Status.java b/src/main/java/im/toduck/domain/inquiry/persistence/entity/Status.java new file mode 100644 index 00000000..948ae0c8 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/persistence/entity/Status.java @@ -0,0 +1,6 @@ +package im.toduck.domain.inquiry.persistence.entity; + +public enum Status { + PENDING, + ANSWERED +} diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/entity/Type.java b/src/main/java/im/toduck/domain/inquiry/persistence/entity/Type.java new file mode 100644 index 00000000..5d591a75 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/persistence/entity/Type.java @@ -0,0 +1,8 @@ +package im.toduck.domain.inquiry.persistence.entity; + +public enum Type { + ERROR, + USAGE, + SUGGESTION, + ETC +} diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryAnswerRepository.java b/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryAnswerRepository.java new file mode 100644 index 00000000..26ce02de --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryAnswerRepository.java @@ -0,0 +1,22 @@ +package im.toduck.domain.inquiry.persistence.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import im.toduck.domain.inquiry.persistence.entity.Inquiry; +import im.toduck.domain.inquiry.persistence.entity.InquiryAnswer; + +@Repository +public interface InquiryAnswerRepository extends JpaRepository { + boolean existsByInquiry(Inquiry inquiry); + + @Query(value = """ + SELECT * + FROM inquiry_answer + WHERE inquiry_id = :inquiryId + """, nativeQuery = true) + Optional findAnyByInquiryIdIncludingDeleted(Long inquiryId); +} diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryImgRepository.java b/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryImgRepository.java new file mode 100644 index 00000000..06555d07 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryImgRepository.java @@ -0,0 +1,14 @@ +package im.toduck.domain.inquiry.persistence.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import im.toduck.domain.inquiry.persistence.entity.Inquiry; +import im.toduck.domain.inquiry.persistence.entity.InquiryImage; + +@Repository +public interface InquiryImgRepository extends JpaRepository { + List findAllByInquiry(Inquiry inquiry); +} diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryRepository.java b/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryRepository.java new file mode 100644 index 00000000..57d772b2 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/persistence/repository/InquiryRepository.java @@ -0,0 +1,10 @@ +package im.toduck.domain.inquiry.persistence.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import im.toduck.domain.inquiry.persistence.entity.Inquiry; + +@Repository +public interface InquiryRepository extends JpaRepository { +} diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustom.java b/src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustom.java new file mode 100644 index 00000000..62036b74 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustom.java @@ -0,0 +1,11 @@ +package im.toduck.domain.inquiry.persistence.repository.querydsl; + +import java.util.List; + +import im.toduck.domain.inquiry.persistence.entity.Inquiry; + +public interface InquiryRepositoryCustom { + List findWithImgs(final Long userId); + + List findAllWithImgs(); +} diff --git a/src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustomImpl.java b/src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustomImpl.java new file mode 100644 index 00000000..00357666 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/persistence/repository/querydsl/InquiryRepositoryCustomImpl.java @@ -0,0 +1,57 @@ +package im.toduck.domain.inquiry.persistence.repository.querydsl; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import im.toduck.domain.inquiry.persistence.entity.Inquiry; +import im.toduck.domain.inquiry.persistence.entity.QInquiry; +import im.toduck.domain.inquiry.persistence.entity.QInquiryAnswer; +import im.toduck.domain.inquiry.persistence.entity.QInquiryImage; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class InquiryRepositoryCustomImpl implements InquiryRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findWithImgs(final Long userId) { + QInquiry iq = QInquiry.inquiry; + QInquiryImage iqi = QInquiryImage.inquiryImage; + QInquiryAnswer iqa = QInquiryAnswer.inquiryAnswer; + + return queryFactory + .selectFrom(iq) + .leftJoin(iq.inquiryImages, iqi).fetchJoin() + .leftJoin(iq.inquiryAnswer, iqa).fetchJoin() + .leftJoin(iqa.admin).fetchJoin() + .where( + iq.user.id.eq(userId), + iq.deletedAt.isNull() + ) + .orderBy(iq.createdAt.desc()) + .distinct() + .fetch(); + } + + @Override + public List findAllWithImgs() { + QInquiry iq = QInquiry.inquiry; + QInquiryImage iqi = QInquiryImage.inquiryImage; + QInquiryAnswer iqa = QInquiryAnswer.inquiryAnswer; + + return queryFactory + .selectFrom(iq) + .leftJoin(iq.inquiryImages, iqi).fetchJoin() + .leftJoin(iq.inquiryAnswer, iqa).fetchJoin() + .leftJoin(iqa.admin).fetchJoin() + .where(iq.deletedAt.isNull()) + .orderBy(iq.createdAt.desc()) + .distinct() + .fetch(); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/api/InquiryAnswerApi.java b/src/main/java/im/toduck/domain/inquiry/presentation/api/InquiryAnswerApi.java new file mode 100644 index 00000000..40ea2901 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/presentation/api/InquiryAnswerApi.java @@ -0,0 +1,78 @@ +package im.toduck.domain.inquiry.presentation.api; + +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerUpdateRequest; +import im.toduck.global.annotation.swagger.ApiErrorResponseExplanation; +import im.toduck.global.annotation.swagger.ApiResponseExplanations; +import im.toduck.global.annotation.swagger.ApiSuccessResponseExplanation; +import im.toduck.global.exception.ExceptionCode; +import im.toduck.global.presentation.ApiResponse; +import im.toduck.global.security.authentication.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "InquiryAnswer") +public interface InquiryAnswerApi { + @Operation( + summary = "문의 답변 생성", + description = "관리자가 문의 답변을 생성합니다." + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + description = "문의 답변 생성 성공. 빈 content 객체를 반환합니다." + ), + errors = { + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_ADMIN), + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_INQUIRY), + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.ALREADY_ANSWERED_INQUIRY) + } + ) + ResponseEntity>> createInquiryAnswer( + @RequestBody @Valid final InquiryAnswerCreateRequest request, + @AuthenticationPrincipal final CustomUserDetails userDetails + ); + + @Operation( + summary = "문의 답변 수정", + description = "관리자가 문의 답변을 수정합니다." + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + description = "문의 답변 수정 성공. 빈 content 객체를 반환합니다." + ), + errors = { + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_ADMIN), + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_INQUIRY) + } + ) + ResponseEntity>> updateInquiryAnswer( + @PathVariable final Long inquiryId, + @RequestBody @Valid final InquiryAnswerUpdateRequest request, + @AuthenticationPrincipal final CustomUserDetails userDetails + ); + + @Operation( + summary = "문의 답변 삭제", + description = "관리자가 문의 답변을 삭제합니다." + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + description = "문의 답변 삭제 성공. 빈 content 객체를 반환합니다." + ), + errors = { + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_INQUIRY), + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_INQUIRY_ANSWER) + } + ) + ResponseEntity>> deleteInquiryAnswer( + @PathVariable final Long inquiryId + ); +} diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/api/InquiryApi.java b/src/main/java/im/toduck/domain/inquiry/presentation/api/InquiryApi.java new file mode 100644 index 00000000..41a1c5d7 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/presentation/api/InquiryApi.java @@ -0,0 +1,111 @@ +package im.toduck.domain.inquiry.presentation.api; + +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import im.toduck.domain.inquiry.presentation.dto.request.InquiryCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryUpdateRequest; +import im.toduck.domain.inquiry.presentation.dto.response.InquiryListResponse; +import im.toduck.global.annotation.swagger.ApiErrorResponseExplanation; +import im.toduck.global.annotation.swagger.ApiResponseExplanations; +import im.toduck.global.annotation.swagger.ApiSuccessResponseExplanation; +import im.toduck.global.exception.ExceptionCode; +import im.toduck.global.presentation.ApiResponse; +import im.toduck.global.security.authentication.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "Inquiry") +public interface InquiryApi { + @Operation( + summary = "문의 목록 조회", + description = "사용자가 자신의 문의 목록을 조회합니다." + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + responseClass = InquiryListResponse.class, + description = "문의 목록 조회 성공. 문의 목록을 반환합니다." + ) + ) + ResponseEntity> getInquiries( + @AuthenticationPrincipal final CustomUserDetails userDetails + ); + + @Operation( + summary = "문의 생성", + description = "사용자가 문의를 생성합니다." + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + description = "문의 생성 성공. 빈 content 객체를 반환합니다." + ), + errors = { + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_ADMIN) + } + ) + ResponseEntity>> createInquiry( + @RequestBody @Valid final InquiryCreateRequest request, + @AuthenticationPrincipal final CustomUserDetails userDetails + ); + + @Operation( + summary = "문의 수정", + description = + """ + 문의를 수정합니다.
+ "inquiryId" : 문의 일련번호
+ "type" : 문의 타입 [ERROR, USAGE, SUGGESTION, ETC]
+ "inquiryImgs" : 문의 이미지 url

+ 수정하지 않을 경우 null을 입력하면 됩니다. 이미지의 경우 빈 리스트를 넣으면 이미지가 전부 삭제됩니다. + """ + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + description = "문의 수정 성공. 빈 content 객체를 반환합니다." + ), + errors = { + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_INQUIRY) + } + ) + ResponseEntity>> updateInquiry( + @PathVariable final Long inquiryId, + @RequestBody @Valid final InquiryUpdateRequest request, + @AuthenticationPrincipal final CustomUserDetails userDetails + ); + + @Operation( + summary = "문의 삭제", + description = "문의를 삭제합니다." + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + description = "문의 삭제 성공, 빈 content 객체를 반환합니다." + ), + errors = { + @ApiErrorResponseExplanation(exceptionCode = ExceptionCode.NOT_FOUND_INQUIRY) + } + ) + ResponseEntity>> deleteInquiry( + @PathVariable final Long inquiryId, + @AuthenticationPrincipal final CustomUserDetails userDetails + ); + + @Operation( + summary = "전체 문의 목록 조회", + description = "관리자가 전체 문의 목록을 조회합니다." + ) + @ApiResponseExplanations( + success = @ApiSuccessResponseExplanation( + responseClass = InquiryListResponse.class, + description = "전체 문의 목록 조회 성공. 문의 목록을 반환합니다." + ) + ) + ResponseEntity> getAllInquiries( + @AuthenticationPrincipal final CustomUserDetails userDetails + ); +} diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerController.java b/src/main/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerController.java new file mode 100644 index 00000000..9b3c3503 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerController.java @@ -0,0 +1,67 @@ +package im.toduck.domain.inquiry.presentation.controller; + +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import im.toduck.domain.inquiry.domain.usecase.InquiryAnswerUseCase; +import im.toduck.domain.inquiry.presentation.api.InquiryAnswerApi; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerUpdateRequest; +import im.toduck.global.presentation.ApiResponse; +import im.toduck.global.security.authentication.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/inquiry-answers") +public class InquiryAnswerController implements InquiryAnswerApi { + + private final InquiryAnswerUseCase inquiryAnswerUseCase; + + @Override + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity>> createInquiryAnswer( + @RequestBody @Valid final InquiryAnswerCreateRequest request, + @AuthenticationPrincipal final CustomUserDetails userDetails + ) { + inquiryAnswerUseCase.createInquiryAnswer(request, userDetails.getUserId()); + + return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); + } + + @Override + @PatchMapping("/{inquiryId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity>> updateInquiryAnswer( + @PathVariable final Long inquiryId, + @RequestBody @Valid final InquiryAnswerUpdateRequest request, + @AuthenticationPrincipal final CustomUserDetails userDetails + ) { + inquiryAnswerUseCase.updateInquiryAnswer(inquiryId, request, userDetails.getUserId()); + + return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); + } + + @Override + @DeleteMapping("/{inquiryId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity>> deleteInquiryAnswer( + @PathVariable final Long inquiryId + ) { + inquiryAnswerUseCase.deleteInquiryAnswer(inquiryId); + + return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/controller/InquiryController.java b/src/main/java/im/toduck/domain/inquiry/presentation/controller/InquiryController.java new file mode 100644 index 00000000..2dd96499 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/presentation/controller/InquiryController.java @@ -0,0 +1,91 @@ +package im.toduck.domain.inquiry.presentation.controller; + +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import im.toduck.domain.inquiry.domain.usecase.InquiryUseCase; +import im.toduck.domain.inquiry.presentation.api.InquiryApi; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryUpdateRequest; +import im.toduck.domain.inquiry.presentation.dto.response.InquiryListResponse; +import im.toduck.global.presentation.ApiResponse; +import im.toduck.global.security.authentication.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/inquiries") +public class InquiryController implements InquiryApi { + private final InquiryUseCase inquiryUseCase; + + @Override + @GetMapping("/me") + @PreAuthorize("isAuthenticated()") + public ResponseEntity> getInquiries( + @AuthenticationPrincipal final CustomUserDetails userDetails + ) { + InquiryListResponse response = inquiryUseCase.getInquiries(userDetails.getUserId()); + + return ResponseEntity.ok(ApiResponse.createSuccess(response)); + } + + @Override + @PostMapping + @PreAuthorize("isAuthenticated()") + public ResponseEntity>> createInquiry( + @RequestBody @Valid final InquiryCreateRequest request, + @AuthenticationPrincipal final CustomUserDetails userDetails + ) { + inquiryUseCase.createInquiry(request, userDetails.getUserId()); + + return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); + } + + @Override + @PatchMapping("/{inquiryId}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity>> updateInquiry( + @PathVariable final Long inquiryId, + @RequestBody @Valid final InquiryUpdateRequest request, + @AuthenticationPrincipal final CustomUserDetails userDetails + ) { + inquiryUseCase.updateInquiry(inquiryId, request, userDetails.getUserId()); + + return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); + } + + @Override + @DeleteMapping("/{inquiryId}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity>> deleteInquiry( + @PathVariable final Long inquiryId, + @AuthenticationPrincipal final CustomUserDetails userDetails + ) { + inquiryUseCase.deleteInquiry(inquiryId, userDetails.getUserId()); + + return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoContent()); + } + + @Override + @GetMapping("/admin") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> getAllInquiries( + @AuthenticationPrincipal final CustomUserDetails userDetails + ) { + InquiryListResponse response = inquiryUseCase.getAllInquiries(userDetails.getUserId()); + + return ResponseEntity.ok(ApiResponse.createSuccess(response)); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerCreateRequest.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerCreateRequest.java new file mode 100644 index 00000000..16aed97f --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerCreateRequest.java @@ -0,0 +1,21 @@ +package im.toduck.domain.inquiry.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +@Schema(description = "문의 답변 생성 요청 DTO") +public record InquiryAnswerCreateRequest( + @NotNull(message = "문의 ID는 비어있을 수 없습니다.") + @Schema(description = "문의 ID", example = "1") + Long inquiryId, + + @NotBlank + @Size(max = 1023, message = "문의 답변은 1023자를 초과할 수 없습니다.") + @Schema(description = "문의 답변", example = "현재로써는 루틴을 반복할 기간을 따로 설정할 수 있는 기능은 없습니다!") + String content +) { +} diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerUpdateRequest.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerUpdateRequest.java new file mode 100644 index 00000000..591b3613 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryAnswerUpdateRequest.java @@ -0,0 +1,16 @@ +package im.toduck.domain.inquiry.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +@Schema(description = "문의 답변 수정 요청 DTO") +public record InquiryAnswerUpdateRequest( + @NotBlank + @Size(max = 1023, message = "문의 답변은 1023자를 초과할 수 없습니다.") + @Schema(description = "문의 답변", example = "도움이 되셨기를 바라며, 좋은 하루 되세요 :)") + String content +) { +} diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryCreateRequest.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryCreateRequest.java new file mode 100644 index 00000000..e71d6b1e --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryCreateRequest.java @@ -0,0 +1,27 @@ +package im.toduck.domain.inquiry.presentation.dto.request; + +import java.util.List; + +import im.toduck.domain.inquiry.persistence.entity.Type; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +@Schema(description = "사용자 문의 생성 요청 DTO") +public record InquiryCreateRequest( + @NotNull(message = "문의 유형은 비어있을 수 없습니다.") + @Schema(description = "문의 유형", example = "ERROR") + Type type, + + @Size(max = 500, message = "내용은 500자를 초과할 수 없습니다.") + @NotBlank(message = "내용은 비어있을 수 없습니다.") + @Schema(description = "문의 내용", example = "설정한 루틴의 순서를 바꾸고 싶은데 어떻게 하는지 모르겠어요 ㅠㅠ") + String content, + + @Schema(description = "문의 사진 URL 목록", example = "[\"https://cdn.toduck.app/image1.jpg\"]") + List inquiryImgs +) { +} diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryUpdateRequest.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryUpdateRequest.java new file mode 100644 index 00000000..a6f38e00 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/request/InquiryUpdateRequest.java @@ -0,0 +1,27 @@ +package im.toduck.domain.inquiry.presentation.dto.request; + +import java.util.List; + +import im.toduck.domain.inquiry.persistence.entity.Type; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +@Builder +@Schema(description = "문의 수정 요청 DTO") +public record InquiryUpdateRequest( + @NotNull(message = "문의 유형은 비어있을 수 없습니다.") + @Schema(description = "문의 유형", example = "USAGE") + Type type, + + @Size(max = 500, message = "내용은 500자를 초과할 수 없습니다.") + @NotBlank(message = "내용은 비어있을 수 없습니다.") + @Schema(description = "문의 내용", example = "루틴을 매 달 반복되도록 설정할 수 있나요?") + String content, + + @Schema(description = "변경된 이미지 URL 목록", example = "[\"https://cdn.app/image123.jpg\"]") + List inquiryImgs +) { +} diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/response/InquiryListResponse.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/response/InquiryListResponse.java new file mode 100644 index 00000000..5fcf9efc --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/response/InquiryListResponse.java @@ -0,0 +1,17 @@ +package im.toduck.domain.inquiry.presentation.dto.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "문의 내역 목록 응답") +public record InquiryListResponse( + @Schema(description = "문의 내역 목록") + List inquiryDtos +) { + public static InquiryListResponse toListInquiryResponse( + final List inquiries + ) { + return new InquiryListResponse(inquiries); + } +} diff --git a/src/main/java/im/toduck/domain/inquiry/presentation/dto/response/InquiryResponse.java b/src/main/java/im/toduck/domain/inquiry/presentation/dto/response/InquiryResponse.java new file mode 100644 index 00000000..7bc1bc57 --- /dev/null +++ b/src/main/java/im/toduck/domain/inquiry/presentation/dto/response/InquiryResponse.java @@ -0,0 +1,52 @@ +package im.toduck.domain.inquiry.presentation.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; + +import im.toduck.domain.inquiry.persistence.entity.Status; +import im.toduck.domain.inquiry.persistence.entity.Type; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "문의 내역 응답") +public record InquiryResponse( + @Schema(description = "문의 ID", example = "1") + Long inquiryId, + + @Schema(description = "유형", example = "이용 문의") + Type type, + + @Schema(description = "내용", example = "문의 내용") + String content, + + @Schema(description = "상태", example = "PENDING") + Status status, + + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") + @Schema(description = "생성 날짜", example = "2026-01-22T14:30:00") + LocalDateTime createdAt, + + @Schema(description = "문의 내역 이미지 url 목록", example = "[\"https://cdn.toduck.app/image1.jpg\"]") + List inquiryImgUrl, + + @Schema(description = "문의 답변 ID", example = "1") + Long inquiryAnswerId, + + @Schema(description = "답변 작성자 이름", example = "토덕 관리자") + String answerAdminName, + + @Schema(description = "문의 내용", example = "현재로써는 루틴을 반복할 기간을 따로 설정할 수 있는 기능은 없습니다!") + String answerContent, + + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") + @Schema(description = "답변 생성 날짜", example = "2026-01-22T14:30:00") + LocalDateTime answerCreatedAt +) { +} diff --git a/src/main/java/im/toduck/domain/mypage/domain/usecase/MyPageUseCase.java b/src/main/java/im/toduck/domain/mypage/domain/usecase/MyPageUseCase.java index dfc391f6..dc4c176a 100644 --- a/src/main/java/im/toduck/domain/mypage/domain/usecase/MyPageUseCase.java +++ b/src/main/java/im/toduck/domain/mypage/domain/usecase/MyPageUseCase.java @@ -4,6 +4,8 @@ import org.springframework.transaction.annotation.Transactional; +import im.toduck.domain.admin.domain.service.AdminService; +import im.toduck.domain.admin.persistence.entity.Admin; import im.toduck.domain.mypage.common.mapper.MyPageMapper; import im.toduck.domain.mypage.domain.service.MyPageService; import im.toduck.domain.mypage.presentation.dto.request.NickNameUpdateRequest; @@ -16,6 +18,7 @@ import im.toduck.domain.user.domain.service.FollowService; import im.toduck.domain.user.domain.service.UserService; import im.toduck.domain.user.persistence.entity.User; +import im.toduck.domain.user.persistence.entity.UserRole; import im.toduck.global.annotation.UseCase; import im.toduck.global.exception.CommonException; import im.toduck.global.exception.ExceptionCode; @@ -32,6 +35,7 @@ public class MyPageUseCase { private final MyPageService myPageService; private final FollowService followService; private final SocialBoardService socialBoardService; + private final AdminService adminService; @Transactional public void updateNickname(Long userId, NickNameUpdateRequest request) { @@ -64,6 +68,10 @@ public void deleteAccount(Long userId, UserDeleteRequest request) { .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER)); myPageService.recordUserDeletionLog(user, request); + if (user.getRole() == UserRole.ADMIN) { + Admin admin = adminService.getAdmin(user.getId()); + adminService.deleteAdmin(admin); + } this.deleteUserData(user); } diff --git a/src/main/java/im/toduck/domain/user/persistence/entity/User.java b/src/main/java/im/toduck/domain/user/persistence/entity/User.java index 7598743a..95817682 100644 --- a/src/main/java/im/toduck/domain/user/persistence/entity/User.java +++ b/src/main/java/im/toduck/domain/user/persistence/entity/User.java @@ -121,4 +121,12 @@ public void unsuspend() { this.suspendedUntil = null; this.suspensionReason = null; } + + public void promoteToAdmin() { + this.role = UserRole.ADMIN; + } + + public void demoteToUser() { + this.role = UserRole.USER; + } } diff --git a/src/main/java/im/toduck/global/exception/ExceptionCode.java b/src/main/java/im/toduck/global/exception/ExceptionCode.java index f0a5c573..78b96d3d 100644 --- a/src/main/java/im/toduck/global/exception/ExceptionCode.java +++ b/src/main/java/im/toduck/global/exception/ExceptionCode.java @@ -124,6 +124,18 @@ public enum ExceptionCode { NOT_FOUND_EVENTS_SOCIAL(HttpStatus.NOT_FOUND, 41203, "소셜 이벤트 참여 정보를 찾을 수 없습니다."), INVALID_CONTENT_LENGTH(HttpStatus.BAD_REQUEST, 41204, "소셜 게시글 글자 수가 이벤트 참여 조건에 부합하지 않습니다."), + /* 420xx Inquiry */ + NOT_FOUND_INQUIRY(HttpStatus.NOT_FOUND, 42001, "문의 정보를 찾을 수 없습니다."), + UNAUTHORIZED_ACCESS_INQUIRY(HttpStatus.FORBIDDEN, 42002, "문의에 접근 권한이 없습니다."), + + /* 421xx InquiryAnswer */ + NOT_FOUND_INQUIRY_ANSWER(HttpStatus.NOT_FOUND, 42101, "문의 답변 정보를 찾을 수 없습니다."), + ALREADY_ANSWERED_INQUIRY(HttpStatus.CONFLICT, 42102, "이미 문의 답변이 존재합니다."), + + /* 422xx Admin */ + NOT_FOUND_ADMIN(HttpStatus.NOT_FOUND, 42201, "관리자 정보를 찾을 수 없습니다."), + DUPLICATE_ADMIN(HttpStatus.CONFLICT, 42202, "이미 관리자 정보가 존재합니다."), + /* 431xx schedule */ NOT_FOUND_SCHEDULE_RECORD(HttpStatus.NOT_FOUND, 43101, "일정 기록을 찾을 수 없습니다.", "일정 기록을 찾을 수 없을 때 발생하는 오류입니다."), diff --git a/src/test/java/im/toduck/domain/admin/presentation/controller/AdminControllerTest.java b/src/test/java/im/toduck/domain/admin/presentation/controller/AdminControllerTest.java new file mode 100644 index 00000000..dc8af1a8 --- /dev/null +++ b/src/test/java/im/toduck/domain/admin/presentation/controller/AdminControllerTest.java @@ -0,0 +1,174 @@ +package im.toduck.domain.admin.presentation.controller; + +import static im.toduck.fixtures.user.UserFixtures.*; +import static org.assertj.core.api.SoftAssertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import im.toduck.ServiceTest; +import im.toduck.domain.admin.domain.usecase.AdminUseCase; +import im.toduck.domain.admin.persistence.entity.Admin; +import im.toduck.domain.admin.persistence.repository.AdminRepository; +import im.toduck.domain.admin.presentation.dto.request.AdminCreateRequest; +import im.toduck.domain.admin.presentation.dto.request.AdminUpdateRequest; +import im.toduck.domain.admin.presentation.dto.response.AdminListResponse; +import im.toduck.domain.admin.presentation.dto.response.AdminResponse; +import im.toduck.domain.user.persistence.entity.OAuthProvider; +import im.toduck.domain.user.persistence.entity.User; +import im.toduck.domain.user.persistence.entity.UserRole; +import im.toduck.domain.user.persistence.repository.UserRepository; + +class AdminControllerTest extends ServiceTest { + + @Autowired + AdminUseCase adminUseCase; + + @Autowired + AdminRepository adminRepository; + + @Autowired + UserRepository userRepository; + + @Transactional + @Nested + @DisplayName("관리자") + class AdminTest { + private User savedAdminUser, savedGeneralUser; + + @BeforeEach + void setUp() { + savedAdminUser = userRepository.save( + User.builder() + .role(UserRole.ADMIN) + .nickname("admin") + .email("admin@naver.com") + .provider(OAuthProvider.APPLE) + .build() + ); + + savedGeneralUser = testFixtureBuilder.buildUser(GENERAL_USER()); + } + + @Test + void 성공적으로_생성_조회한다() { + // given - when + AdminCreateRequest adminCreateRequest = + AdminCreateRequest.builder() + .userId(savedAdminUser.getId()) + .displayName("토덕토덕") + .build(); + + AdminCreateRequest adminCreateRequest2 = + AdminCreateRequest.builder() + .userId(savedGeneralUser.getId()) + .displayName("토닥토닥") + .build(); + + Admin admin = adminUseCase.createAdmin(adminCreateRequest); + Admin admin2 = adminUseCase.createAdmin(adminCreateRequest2); + + // then + AdminResponse adminResponse = adminUseCase.getAdmin(savedAdminUser.getId()); + AdminResponse adminResponse2 = adminUseCase.getAdmin(savedGeneralUser.getId()); + AdminListResponse adminListResponse = adminUseCase.getAdmins(); + + assertSoftly(softly -> { + softly.assertThat(adminResponse.userId()).isEqualTo(admin.getUser().getId()); + softly.assertThat(adminResponse.displayName()).isEqualTo(admin.getDisplayName()); + }); + + assertSoftly(softly -> { + softly.assertThat(adminResponse2.userId()).isEqualTo(admin2.getUser().getId()); + softly.assertThat(adminResponse2.displayName()).isEqualTo(admin2.getDisplayName()); + softly.assertThat(savedGeneralUser.getRole()).isEqualTo(UserRole.ADMIN); + }); + + assertSoftly(softly -> { + softly.assertThat(adminListResponse.adminDtos().size()).isEqualTo(2); + softly.assertThat(adminListResponse.adminDtos().get(0).displayName()).isEqualTo(admin.getDisplayName()); + softly.assertThat(adminListResponse.adminDtos().get(1).displayName()) + .isEqualTo(admin2.getDisplayName()); + }); + } + + @Test + void 성공적으로_수정한다() { + // given + AdminCreateRequest adminCreateRequest = + AdminCreateRequest.builder() + .userId(savedAdminUser.getId()) + .displayName("토덕토덕") + .build(); + + Admin admin = adminUseCase.createAdmin(adminCreateRequest); + + // when + AdminUpdateRequest adminUpdateRequest = + AdminUpdateRequest.builder() + .displayName("토닥토닥") + .build(); + + adminUseCase.updateAdmin(savedAdminUser.getId(), adminUpdateRequest); + + // then + assertSoftly(softly -> { + softly.assertThat(admin.getDisplayName()).isEqualTo("토닥토닥"); + }); + } + + @Test + void 성공적으로_삭제한다() { + // given + AdminCreateRequest adminCreateRequest = + AdminCreateRequest.builder() + .userId(savedAdminUser.getId()) + .displayName("토덕토덕") + .build(); + + Admin admin = adminUseCase.createAdmin(adminCreateRequest); + + // when + adminUseCase.deleteAdmin(admin.getUser().getId()); + AdminListResponse adminListResponse = adminUseCase.getAdmins(); + + // then + assertSoftly(softly -> { + softly.assertThat(adminListResponse.adminDtos().size()).isEqualTo(0); + softly.assertThat(savedAdminUser.getRole()).isEqualTo(UserRole.USER); + }); + } + + @Test + void 삭제되지_않은_관리자만_조회한다() { + // given - when + AdminCreateRequest adminCreateRequest = + AdminCreateRequest.builder() + .userId(savedAdminUser.getId()) + .displayName("토덕토덕") + .build(); + + AdminCreateRequest adminCreateRequest2 = + AdminCreateRequest.builder() + .userId(savedGeneralUser.getId()) + .displayName("토닥토닥") + .build(); + + Admin admin = adminUseCase.createAdmin(adminCreateRequest); + Admin admin2 = adminUseCase.createAdmin(adminCreateRequest2); + + adminUseCase.deleteAdmin(admin2.getUser().getId()); + + // then + AdminListResponse adminListResponse = adminUseCase.getAdmins(); + + assertSoftly(softly -> { + softly.assertThat(adminListResponse.adminDtos().size()).isEqualTo(1); + }); + } + } +} diff --git a/src/test/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCaseTest.java b/src/test/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCaseTest.java index 37058e8f..a969231b 100644 --- a/src/test/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCaseTest.java +++ b/src/test/java/im/toduck/domain/events/detail/domain/usecase/EventsDetailUseCaseTest.java @@ -116,15 +116,6 @@ void setUp() { .eventsDetailImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ")) .build(); - EventsDetailCreateRequest eventsDetailCreateRequest2 = - EventsDetailCreateRequest.builder() - .eventsId(savedEvent.getId()) - .routingUrl("toduck://anotherPost") - .buttonVisible(true) - .buttonText("당첨 확인하기") - .eventsDetailImgs(Arrays.asList("xyz", "123")) - .build(); - EventsDetail eventsDetail1 = eventsDetailUseCase.createEventsDetail(eventsDetailCreateRequest, savedAdminUser.getId()); diff --git a/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerControllerTest.java b/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerControllerTest.java new file mode 100644 index 00000000..91d16cd2 --- /dev/null +++ b/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryAnswerControllerTest.java @@ -0,0 +1,364 @@ +package im.toduck.domain.inquiry.presentation.controller; + +import static im.toduck.fixtures.user.UserFixtures.*; +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.assertj.core.api.SoftAssertions.*; + +import java.util.Arrays; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import im.toduck.ServiceTest; +import im.toduck.domain.admin.domain.usecase.AdminUseCase; +import im.toduck.domain.admin.persistence.entity.Admin; +import im.toduck.domain.admin.persistence.repository.AdminRepository; +import im.toduck.domain.inquiry.domain.usecase.InquiryAnswerUseCase; +import im.toduck.domain.inquiry.domain.usecase.InquiryUseCase; +import im.toduck.domain.inquiry.persistence.entity.Inquiry; +import im.toduck.domain.inquiry.persistence.entity.InquiryAnswer; +import im.toduck.domain.inquiry.persistence.entity.Status; +import im.toduck.domain.inquiry.persistence.entity.Type; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryAnswerUpdateRequest; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.response.InquiryListResponse; +import im.toduck.domain.user.persistence.entity.OAuthProvider; +import im.toduck.domain.user.persistence.entity.User; +import im.toduck.domain.user.persistence.entity.UserRole; +import im.toduck.domain.user.persistence.repository.UserRepository; +import im.toduck.global.exception.CommonException; +import im.toduck.global.exception.ExceptionCode; + +class InquiryAnswerControllerTest extends ServiceTest { + + @Autowired + InquiryUseCase inquiryUseCase; + + @Autowired + InquiryAnswerUseCase inquiryAnswerUseCase; + + @Autowired + AdminUseCase adminUseCase; + + @Autowired + UserRepository userRepository; + + @Autowired + AdminRepository adminRepository; + + @Transactional + @Nested + @DisplayName("문의 답변") + class InquiryAnswerTest { + private User savedAdminUser, savedGeneralUser; + + String content = "설정한 루틴의 순서를 바꾸고 싶은데 어떻게 하는지 모르겠어요 ㅠㅠ"; + String content2 = "루틴을 매 달 반복되도록 설정할 수 있나요?"; + + String answer = "현재로써는 루틴을 반복할 기간을 따로 설정할 수 있는 기능은 없습니다!"; + String answer2 = "도움이 되셨기를 바라며, 좋은 하루 되세요 :)"; + + InquiryCreateRequest inquiryCreateRequest = + InquiryCreateRequest.builder() + .type(Type.ERROR) + .content(content) + .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ")) + .build(); + + InquiryCreateRequest inquiryCreateRequest2 = + InquiryCreateRequest.builder() + .type(Type.USAGE) + .content(content2) + .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ")) + .build(); + + @BeforeEach + void setUp() { + savedAdminUser = userRepository.save( + User.builder() + .role(UserRole.ADMIN) + .nickname("admin") + .email("admin@naver.com") + .provider(OAuthProvider.APPLE) + .build() + ); + + savedGeneralUser = testFixtureBuilder.buildUser(GENERAL_USER()); + } + + @Transactional + @Test + void 성공적으로_생성_조회한다() { + // given + Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId()); + Inquiry inquiry2 = inquiryUseCase.createInquiry(inquiryCreateRequest2, savedGeneralUser.getId()); + + Admin admin = adminRepository.save( + Admin.builder() + .user(savedAdminUser) + .displayName("토덕 관리자") + .build() + ); + + // when + InquiryAnswerCreateRequest request = InquiryAnswerCreateRequest.builder() + .inquiryId(inquiry.getId()) + .content(answer) + .build(); + + InquiryAnswerCreateRequest request2 = InquiryAnswerCreateRequest.builder() + .inquiryId(inquiry2.getId()) + .content(answer2) + .build(); + + InquiryAnswer inquiryAnswer = inquiryAnswerUseCase.createInquiryAnswer(request, + admin.getUser().getId()); + + InquiryAnswer inquiryAnswer2 = inquiryAnswerUseCase.createInquiryAnswer(request2, + admin.getUser().getId()); + + // then + InquiryListResponse response = inquiryUseCase.getInquiries(savedGeneralUser.getId()); + + assertSoftly(softly -> { + softly.assertThat(response.inquiryDtos().size()).isEqualTo(2L); + softly.assertThat(response.inquiryDtos().get(1).type()) + .isEqualTo(inquiryCreateRequest.type()); + softly.assertThat(response.inquiryDtos().get(1).content()).isEqualTo(content); + softly.assertThat(response.inquiryDtos().get(1).status()).isEqualTo(Status.ANSWERED); + softly.assertThat(response.inquiryDtos().get(1).inquiryAnswerId()) + .isEqualTo(inquiryAnswer.getId()); + softly.assertThat(response.inquiryDtos().get(1).answerAdminName()) + .isEqualTo(admin.getDisplayName()); + softly.assertThat(response.inquiryDtos().get(1).answerContent()).isEqualTo(answer); + softly.assertThat(response.inquiryDtos().get(1).answerCreatedAt()) + .isEqualTo(inquiryAnswer.getCreatedAt()); + + softly.assertThat(response.inquiryDtos().get(0).type()) + .isEqualTo(inquiryCreateRequest2.type()); + softly.assertThat(response.inquiryDtos().get(0).content()).isEqualTo(content2); + softly.assertThat(response.inquiryDtos().get(0).status()).isEqualTo(Status.ANSWERED); + softly.assertThat(response.inquiryDtos().get(0).inquiryAnswerId()) + .isEqualTo(inquiryAnswer2.getId()); + softly.assertThat(response.inquiryDtos().get(0).answerAdminName()) + .isEqualTo(admin.getDisplayName()); + softly.assertThat(response.inquiryDtos().get(0).answerContent()).isEqualTo(answer2); + softly.assertThat(response.inquiryDtos().get(0).answerCreatedAt()) + .isEqualTo(inquiryAnswer2.getCreatedAt()); + }); + } + + @Transactional + @Test + void 이미_답변이_달린_경우_에러가_발생한다() { + // given + Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId()); + + Admin admin = adminRepository.save( + Admin.builder() + .user(savedAdminUser) + .displayName("토덕 관리자") + .build() + ); + + InquiryAnswerCreateRequest request = InquiryAnswerCreateRequest.builder() + .inquiryId(inquiry.getId()) + .content(answer) + .build(); + + InquiryAnswer inquiryAnswer = inquiryAnswerUseCase.createInquiryAnswer(request, + admin.getUser().getId()); + + // when - then + assertThatThrownBy(() -> + inquiryAnswerUseCase.createInquiryAnswer(request, admin.getUser().getId()) + ) + .isInstanceOfSatisfying(CommonException.class, e -> + assertThat(e.getErrorCode()) + .isEqualTo(ExceptionCode.ALREADY_ANSWERED_INQUIRY.getErrorCode()) + ); + } + + @Transactional + @Test + void 답변_작성자의_관리자_권한이_제거된_경우에도_답변_작성자가_표시된다() { + // given + Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId()); + + Admin admin = adminRepository.save( + Admin.builder() + .user(savedAdminUser) + .displayName("토덕 관리자") + .build() + ); + + InquiryAnswerCreateRequest request = InquiryAnswerCreateRequest.builder() + .inquiryId(inquiry.getId()) + .content(answer) + .build(); + + InquiryAnswer inquiryAnswer = inquiryAnswerUseCase.createInquiryAnswer(request, + admin.getUser().getId()); + + // when + adminUseCase.deleteAdmin(admin.getUser().getId()); + + // then + InquiryListResponse response = inquiryUseCase.getInquiries(savedGeneralUser.getId()); + + assertSoftly(softly -> { + softly.assertThat(response.inquiryDtos().size()).isEqualTo(1L); + softly.assertThat(response.inquiryDtos().get(0).type()) + .isEqualTo(inquiryCreateRequest.type()); + softly.assertThat(response.inquiryDtos().get(0).content()).isEqualTo(content); + softly.assertThat(response.inquiryDtos().get(0).status()).isEqualTo(Status.ANSWERED); + softly.assertThat(response.inquiryDtos().get(0).inquiryAnswerId()) + .isEqualTo(inquiryAnswer.getId()); + softly.assertThat(response.inquiryDtos().get(0).answerAdminName()) + .isEqualTo(admin.getDisplayName()); + softly.assertThat(response.inquiryDtos().get(0).answerContent()).isEqualTo(answer); + softly.assertThat(response.inquiryDtos().get(0).answerCreatedAt()) + .isEqualTo(inquiryAnswer.getCreatedAt()); + + softly.assertThat(savedAdminUser.getRole()).isEqualTo(UserRole.USER); + }); + } + + @Transactional + @Test + void 성공적으로_답변을_수정한다() { + // given + Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId()); + + Admin admin = adminRepository.save( + Admin.builder() + .user(savedAdminUser) + .displayName("토덕 관리자") + .build() + ); + + InquiryAnswerCreateRequest request = InquiryAnswerCreateRequest.builder() + .inquiryId(inquiry.getId()) + .content(answer) + .build(); + + InquiryAnswer inquiryAnswer = inquiryAnswerUseCase.createInquiryAnswer(request, + admin.getUser().getId()); + + // when + InquiryAnswerUpdateRequest updateRequest = InquiryAnswerUpdateRequest.builder() + .content(answer2) + .build(); + + inquiryAnswerUseCase.updateInquiryAnswer(inquiry.getId(), updateRequest, admin.getUser().getId()); + + // then + InquiryListResponse response = inquiryUseCase.getInquiries(savedGeneralUser.getId()); + + assertSoftly(softly -> { + softly.assertThat(response.inquiryDtos().size()).isEqualTo(1L); + softly.assertThat(response.inquiryDtos().get(0).type()) + .isEqualTo(inquiryCreateRequest.type()); + softly.assertThat(response.inquiryDtos().get(0).content()).isEqualTo(content); + softly.assertThat(response.inquiryDtos().get(0).status()).isEqualTo(Status.ANSWERED); + softly.assertThat(response.inquiryDtos().get(0).inquiryAnswerId()) + .isEqualTo(inquiryAnswer.getId()); + softly.assertThat(response.inquiryDtos().get(0).answerAdminName()) + .isEqualTo(admin.getDisplayName()); + softly.assertThat(response.inquiryDtos().get(0).answerContent()).isEqualTo(answer2); + softly.assertThat(response.inquiryDtos().get(0).answerCreatedAt()) + .isEqualTo(inquiryAnswer.getCreatedAt()); + softly.assertThat(inquiry.getInquiryAnswer().getUpdatedAt()).isNotNull(); + }); + } + + @Transactional + @Test + void 성공적으로_답변을_삭제한다() { + // given + Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId()); + + Admin admin = adminRepository.save( + Admin.builder() + .user(savedAdminUser) + .displayName("토덕 관리자") + .build() + ); + + InquiryAnswerCreateRequest request = InquiryAnswerCreateRequest.builder() + .inquiryId(inquiry.getId()) + .content(answer) + .build(); + + InquiryAnswer inquiryAnswer = inquiryAnswerUseCase.createInquiryAnswer(request, + admin.getUser().getId()); + + // when + inquiryAnswerUseCase.deleteInquiryAnswer(inquiry.getId()); + + // then + InquiryListResponse response = inquiryUseCase.getInquiries(savedGeneralUser.getId()); + + assertSoftly(softly -> { + softly.assertThat(response.inquiryDtos().size()).isEqualTo(1L); + softly.assertThat(response.inquiryDtos().get(0).type()) + .isEqualTo(inquiryCreateRequest.type()); + softly.assertThat(response.inquiryDtos().get(0).content()).isEqualTo(content); + softly.assertThat(response.inquiryDtos().get(0).status()).isEqualTo(Status.PENDING); + softly.assertThat(response.inquiryDtos().get(0).inquiryAnswerId()).isNull(); + softly.assertThat(response.inquiryDtos().get(0).answerAdminName()).isNull(); + softly.assertThat(response.inquiryDtos().get(0).answerContent()).isNull(); + softly.assertThat(response.inquiryDtos().get(0).answerCreatedAt()).isNull(); + }); + } + + @Transactional + @Test + void 성공적으로_답변_삭제_후_다시_생성한다() { + // given + Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId()); + + Admin admin = adminRepository.save( + Admin.builder() + .user(savedAdminUser) + .displayName("토덕 관리자") + .build() + ); + + InquiryAnswerCreateRequest request = InquiryAnswerCreateRequest.builder() + .inquiryId(inquiry.getId()) + .content(answer) + .build(); + + InquiryAnswer inquiryAnswer = inquiryAnswerUseCase.createInquiryAnswer(request, + admin.getUser().getId()); + + inquiryAnswerUseCase.deleteInquiryAnswer(inquiry.getId()); + + // when + InquiryAnswer inquiryAnswer2 = inquiryAnswerUseCase.createInquiryAnswer(request, admin.getUser().getId()); + + // then + InquiryListResponse response = inquiryUseCase.getInquiries(savedGeneralUser.getId()); + + assertSoftly(softly -> { + softly.assertThat(response.inquiryDtos().size()).isEqualTo(1L); + softly.assertThat(response.inquiryDtos().get(0).type()) + .isEqualTo(inquiryCreateRequest.type()); + softly.assertThat(response.inquiryDtos().get(0).content()).isEqualTo(content); + softly.assertThat(response.inquiryDtos().get(0).status()).isEqualTo(Status.ANSWERED); + softly.assertThat(response.inquiryDtos().get(0).inquiryAnswerId()).isEqualTo(inquiryAnswer2.getId()); + softly.assertThat(response.inquiryDtos().get(0).answerAdminName()).isEqualTo(admin.getDisplayName()); + softly.assertThat(response.inquiryDtos().get(0).answerContent()).isEqualTo(request.content()); + softly.assertThat(response.inquiryDtos().get(0).answerCreatedAt()) + .isEqualTo(inquiryAnswer2.getCreatedAt()); + softly.assertThat(inquiryAnswer2.getDeletedAt()).isNull(); + }); + } + } +} diff --git a/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryControllerTest.java b/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryControllerTest.java new file mode 100644 index 00000000..a17c41e3 --- /dev/null +++ b/src/test/java/im/toduck/domain/inquiry/presentation/controller/InquiryControllerTest.java @@ -0,0 +1,210 @@ +package im.toduck.domain.inquiry.presentation.controller; + +import static im.toduck.fixtures.user.UserFixtures.*; +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.assertj.core.api.SoftAssertions.*; + +import java.util.Arrays; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import im.toduck.ServiceTest; +import im.toduck.domain.inquiry.domain.usecase.InquiryUseCase; +import im.toduck.domain.inquiry.persistence.entity.Inquiry; +import im.toduck.domain.inquiry.persistence.entity.Status; +import im.toduck.domain.inquiry.persistence.entity.Type; +import im.toduck.domain.inquiry.persistence.repository.InquiryRepository; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryCreateRequest; +import im.toduck.domain.inquiry.presentation.dto.request.InquiryUpdateRequest; +import im.toduck.domain.inquiry.presentation.dto.response.InquiryListResponse; +import im.toduck.domain.user.persistence.entity.OAuthProvider; +import im.toduck.domain.user.persistence.entity.User; +import im.toduck.domain.user.persistence.entity.UserRole; +import im.toduck.domain.user.persistence.repository.UserRepository; +import im.toduck.global.exception.CommonException; +import im.toduck.global.exception.ExceptionCode; + +class InquiryControllerTest extends ServiceTest { + + @Autowired + InquiryUseCase inquiryUseCase; + + @Autowired + InquiryRepository inquiryRepository; + + @Autowired + UserRepository userRepository; + + @Transactional + @Nested + @DisplayName("문의") + class InquiryTest { + private User savedAdminUser, savedGeneralUser; + + String content = "설정한 루틴의 순서를 바꾸고 싶은데 어떻게 하는지 모르겠어요 ㅠㅠ"; + String content2 = "루틴을 매 달 반복되도록 설정할 수 있나요?"; + + @BeforeEach + void setUp() { + savedAdminUser = userRepository.save( + User.builder() + .role(UserRole.ADMIN) + .nickname("admin") + .email("admin@naver.com") + .provider(OAuthProvider.APPLE) + .build() + ); + + savedGeneralUser = testFixtureBuilder.buildUser(GENERAL_USER()); + } + + @Test + void 성공적으로_생성한다() { + // given - when + InquiryCreateRequest inquiryCreateRequest = + InquiryCreateRequest.builder() + .type(Type.ERROR) + .content(content) + .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ")) + .build(); + + Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId()); + + // then + Inquiry inquiry2 = inquiryRepository.findById(inquiry.getId()) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_INQUIRY)); + + assertSoftly(softly -> { + softly.assertThat(inquiry2.getUser().getId()) + .isEqualTo(inquiry.getUser().getId()); + softly.assertThat((inquiry2.getType())) + .isEqualTo(inquiry.getType()); + softly.assertThat(inquiry2.getContent()) + .isEqualTo(inquiry.getContent()); + softly.assertThat(inquiry2.getStatus()) + .isEqualTo(inquiry.getStatus()); + softly.assertThat(inquiry2.getCreatedAt()) + .isEqualTo(inquiry.getCreatedAt()); + }); + } + + @Test + void 성공적으로_사용자가_자신의_문의를_조회한다() { + // given + InquiryCreateRequest inquiryCreateRequest = + InquiryCreateRequest.builder() + .type(Type.ERROR) + .content(content) + .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ")) + .build(); + + Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId()); + Inquiry inquiry2 = inquiryUseCase.createInquiry(inquiryCreateRequest, savedAdminUser.getId()); + + // when + InquiryListResponse inquiryListResponse = inquiryUseCase.getInquiries(savedGeneralUser.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(inquiryListResponse.inquiryDtos().size()).isEqualTo(1); + softly.assertThat(inquiryListResponse.inquiryDtos().get(0).type()).isEqualTo(Type.ERROR); + softly.assertThat(inquiryListResponse.inquiryDtos().get(0).content()).isEqualTo(content); + softly.assertThat(inquiryListResponse.inquiryDtos().get(0).status()).isEqualTo(Status.PENDING); + }); + } + + @Test + void 성공적으로_문의를_수정한다() { + // given + InquiryCreateRequest inquiryCreateRequest = + InquiryCreateRequest.builder() + .type(Type.ERROR) + .content(content) + .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ")) + .build(); + + Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId()); + + // when + InquiryUpdateRequest inquiryUpdateRequest = + InquiryUpdateRequest.builder() + .type(Type.USAGE) + .content(content2) + .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ")) + .build(); + + Inquiry inquiry2 = inquiryUseCase.updateInquiry(inquiry.getId(), inquiryUpdateRequest, + savedGeneralUser.getId()); + + InquiryListResponse inquiryListResponse = inquiryUseCase.getInquiries(savedGeneralUser.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(inquiryListResponse.inquiryDtos().size()).isEqualTo(1); + softly.assertThat(inquiryListResponse.inquiryDtos().get(0).type()).isEqualTo(Type.USAGE); + softly.assertThat(inquiryListResponse.inquiryDtos().get(0).content()).isEqualTo(content2); + softly.assertThat(inquiryListResponse.inquiryDtos().get(0).status()).isEqualTo(Status.PENDING); + }); + } + + @Test + void 성공적으로_삭제한다() { + // given + InquiryCreateRequest inquiryCreateRequest = + InquiryCreateRequest.builder() + .type(Type.ERROR) + .content(content) + .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ")) + .build(); + + Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId()); + + // when + inquiryUseCase.deleteInquiry(inquiry.getId(), savedGeneralUser.getId()); + + // then + assertThat(inquiryRepository.findById(inquiry.getId())).isNotPresent(); + } + + @Test + void 성공적으로_관리자가_모든_문의를_조회한다() throws Exception { + // given + InquiryCreateRequest inquiryCreateRequest = + InquiryCreateRequest.builder() + .type(Type.ERROR) + .content(content) + .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ")) + .build(); + + InquiryCreateRequest inquiryCreateRequest2 = + InquiryCreateRequest.builder() + .type(Type.USAGE) + .content(content2) + .inquiryImgs(Arrays.asList("asdf", "ㅁㄴㅇㄹ")) + .build(); + + Inquiry inquiry = inquiryUseCase.createInquiry(inquiryCreateRequest, savedGeneralUser.getId()); + Inquiry inquiry2 = inquiryUseCase.createInquiry(inquiryCreateRequest2, savedAdminUser.getId()); + + // when + InquiryListResponse inquiryListResponse = inquiryUseCase.getAllInquiries(savedAdminUser.getId()); + + // then + assertSoftly(softly -> { + softly.assertThat(inquiryListResponse.inquiryDtos().size()).isEqualTo(2); + softly.assertThat(inquiryListResponse.inquiryDtos().get(1).type()).isEqualTo(Type.ERROR); + softly.assertThat(inquiryListResponse.inquiryDtos().get(1).content()).isEqualTo(content); + softly.assertThat(inquiryListResponse.inquiryDtos().get(1).status()).isEqualTo(Status.PENDING); + + softly.assertThat(inquiryListResponse.inquiryDtos().get(0).type()).isEqualTo(Type.USAGE); + softly.assertThat(inquiryListResponse.inquiryDtos().get(0).content()).isEqualTo(content2); + softly.assertThat(inquiryListResponse.inquiryDtos().get(0).status()).isEqualTo(Status.PENDING); + }); + } + } +}