Skip to content

Commit

Permalink
카테고리 순서 변경 (#988)
Browse files Browse the repository at this point in the history
  • Loading branch information
HoeSeong123 authored Jan 7, 2025
1 parent 90dd931 commit ac3848f
Show file tree
Hide file tree
Showing 48 changed files with 856 additions and 387 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@

import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
Expand All @@ -16,7 +14,7 @@

import codezap.auth.configuration.AuthenticationPrinciple;
import codezap.category.dto.request.CreateCategoryRequest;
import codezap.category.dto.request.UpdateCategoryRequest;
import codezap.category.dto.request.UpdateAllCategoriesRequest;
import codezap.category.dto.response.CreateCategoryResponse;
import codezap.category.dto.response.FindAllCategoriesResponse;
import codezap.category.service.CategoryService;
Expand Down Expand Up @@ -45,19 +43,12 @@ public ResponseEntity<FindAllCategoriesResponse> getCategories(@RequestParam Lon
return ResponseEntity.ok(categoryService.findAllByMemberId(memberId));
}

@PutMapping("/{id}")
@PutMapping
public ResponseEntity<Void> updateCategory(
@AuthenticationPrinciple Member member,
@PathVariable Long id,
@Validated(ValidationSequence.class) @RequestBody UpdateCategoryRequest request
@Validated(ValidationSequence.class) @RequestBody UpdateAllCategoriesRequest request
) {
categoryService.update(member, id, request);
categoryService.updateCategories(member, request);
return ResponseEntity.ok().build();
}

@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteCategory(@AuthenticationPrinciple Member member, @PathVariable Long id) {
categoryService.deleteById(member, id);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import org.springframework.http.ResponseEntity;

import codezap.category.dto.request.CreateCategoryRequest;
import codezap.category.dto.request.UpdateCategoryRequest;
import codezap.category.dto.request.UpdateAllCategoriesRequest;
import codezap.category.dto.response.CreateCategoryResponse;
import codezap.category.dto.response.FindAllCategoriesResponse;
import codezap.global.swagger.error.ApiErrorResponse;
Expand All @@ -31,9 +31,10 @@ public interface SpringDocCategoryController {
@ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/categories", errorCases = {
@ErrorCase(description = "모든 필드 중 null인 값이 있는 경우", exampleMessage = "카테고리 이름이 null 입니다."),
@ErrorCase(description = "카테고리 이름이 15자를 초과한 경우", exampleMessage = "카테고리 이름은 최대 15자까지 입력 가능합니다."),
@ErrorCase(description = "카테고리의 순서가 1보다 작은 경우", exampleMessage = "카테고리의 순서는 1 이상이어야 합니다."),
})
@ApiErrorResponse(status = HttpStatus.CONFLICT, instance = "/categories", errorCases = {
@ErrorCase(description = "동일한 이름의 카테고리가 존재하는 경우", exampleMessage = "이름이 Spring 인 카테고리가 이미 존재합니다."),
@ErrorCase(description = "동일한 이름의 카테고리가 존재하는 경우", exampleMessage = "카테고리명이 중복되었습니다."),
})
ResponseEntity<CreateCategoryResponse> createCategory(
Member member,
Expand All @@ -47,36 +48,27 @@ ResponseEntity<CreateCategoryResponse> createCategory(
ResponseEntity<FindAllCategoriesResponse> getCategories(Long memberId);

@SecurityRequirement(name = "쿠키 인증 토큰")
@Operation(summary = "카테고리 수정", description = "해당하는 식별자의 카테고리를 수정합니다.")
@Operation(summary = "카테고리 생성, 수정, 삭제", description = "카테고리를 생성, 수정, 삭제할 수 있습니다.")
@ApiResponse(responseCode = "200", description = "카테고리 수정 성공")
@ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/categories/1", errorCases = {
@ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/categories", errorCases = {
@ErrorCase(description = "기본 카테고리를 수정 또는 삭제한 경우", exampleMessage = "기본 카테고리는 수정 및 삭제할 수 없습니다."),
@ErrorCase(description = "카테고리 이름이 15자를 초과한 경우", exampleMessage = "카테고리 이름은 최대 15자까지 입력 가능합니다."),
@ErrorCase(description = "카테고리의 순서가 잘못된 경우", exampleMessage = "순서가 잘못되었습니다."),
@ErrorCase(description = "모든 필드 중 null인 값이 있는 경우", exampleMessage = "카테고리 이름이 null 입니다."),
@ErrorCase(description = "삭제하려는 카테고리에 템플릿이 존재하는 경우", exampleMessage = "템플릿이 존재하는 카테고리는 삭제할 수 없습니다."),
@ErrorCase(description = "카테고리의 순서가 1보다 작은 경우", exampleMessage = "카테고리의 순서는 1 이상이어야 합니다."),
@ErrorCase(description = "카테고리의 개수가 일치하지 않는 경우(수정되지 않은 카테고리도 모두 보내주어야 합니다.)",
exampleMessage = "카테고리의 개수가 일치하지 않습니다."),
})
@ApiErrorResponse(status = HttpStatus.NOT_FOUND, instance = "/categories/1", errorCases = {
@ApiErrorResponse(status = HttpStatus.NOT_FOUND, instance = "/categories", errorCases = {
@ErrorCase(description = "해당하는 id 값인 카테고리가 없는 경우", exampleMessage = "식별자 1에 해당하는 카테고리가 존재하지 않습니다."),
})
@ApiErrorResponse(status = HttpStatus.CONFLICT, instance = "/categories", errorCases = {
@ErrorCase(description = "동일한 이름의 카테고리가 존재하는 경우", exampleMessage = "이름이 Spring 인 카테고리가 이미 존재합니다."),
})
@ApiErrorResponse(status = HttpStatus.FORBIDDEN, instance = "/categories/1", errorCases = {
@ErrorCase(description = "카테고리를 수정할 권한이 없는 경우", exampleMessage = "해당 카테고리를 수정 또는 삭제할 권한이 없는 유저입니다.")
})
ResponseEntity<Void> updateCategory(Member member, Long id, UpdateCategoryRequest updateCategoryRequest);

@SecurityRequirement(name = "쿠키 인증 토큰")
@Operation(summary = "카테고리 삭제", description = "해당하는 식별자의 카테고리를 삭제합니다.")
@ApiResponse(responseCode = "204", description = "카테고리 삭제 성공")
@ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/categories/1", errorCases = {
@ErrorCase(description = "삭제하려는 카테고리에 템플릿이 존재하는 경우",
exampleMessage = "템플릿이 존재하는 카테고리는 삭제할 수 없습니다."),
})
@ApiErrorResponse(status = HttpStatus.NOT_FOUND, instance = "/categories/1", errorCases = {
@ErrorCase(description = "존재하지 않는 카테고리인 경우",
exampleMessage = "식별자 1에 해당하는 카테고리가 존재하지 않습니다."),
@ErrorCase(description = "동일한 이름의 카테고리가 존재하는 경우", exampleMessage = "카테고리명이 중복되었습니다."),
@ErrorCase(description = "중복된 id가 있는 경우", exampleMessage = "id가 중복되었습니다."),
})
@ApiErrorResponse(status = HttpStatus.FORBIDDEN, instance = "/categories/1", errorCases = {
@ErrorCase(description = "카테고리를 수정할 권한이 없는 경우",
exampleMessage = "해당 카테고리를 수정 또는 삭제할 권한이 없는 유저입니다.")
@ApiErrorResponse(status = HttpStatus.FORBIDDEN, instance = "/categories", errorCases = {
@ErrorCase(description = "카테고리를 수정 또는 삭제할 권한이 없는 경우", exampleMessage = "해당 카테고리를 수정 또는 삭제할 권한이 없는 유저입니다."),
})
ResponseEntity<Void> deleteCategory(Member member, Long id);
ResponseEntity<Void> updateCategory(Member member, UpdateAllCategoriesRequest request);
}
38 changes: 19 additions & 19 deletions backend/src/main/java/codezap/category/domain/Category.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@
import jakarta.persistence.UniqueConstraint;

import codezap.global.auditing.BaseTimeEntity;
import codezap.global.exception.CodeZapException;
import codezap.global.exception.ErrorCode;
import codezap.member.domain.Member;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
Expand All @@ -25,17 +23,24 @@
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Table(
uniqueConstraints = @UniqueConstraint(
name = "name_with_member",
columnNames = {"member_id", "name"}
),
uniqueConstraints = {
@UniqueConstraint(
name = "name_with_member",
columnNames = {"member_id", "name"}
),
@UniqueConstraint(
name = "ordinal_with_member",
columnNames = {"member_id", "ordinal"}
)
},
indexes = @Index(name = "idx_member_id", columnList = "member_id")
)
@Getter
@EqualsAndHashCode(of = "id", callSuper = false)
public class Category extends BaseTimeEntity {

private static final String DEFAULT_CATEGORY_NAME = "카테고리 없음";
private static final int DEFAULT_CATEGORY_ORDINAL = 0;

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Expand All @@ -50,24 +55,19 @@ public class Category extends BaseTimeEntity {
@Column(nullable = false)
private Boolean isDefault;

public Category(String name, Member member) {
this.name = name;
this.member = member;
this.isDefault = false;
}
@Column(nullable = false)
private int ordinal;

public static Category createDefaultCategory(Member member) {
return new Category(null, member, DEFAULT_CATEGORY_NAME, true);
public Category(String name, Member member, int ordinal) {
this(null, member, name, false, ordinal);
}

public void updateName(String name) {
this.name = name;
public static Category createDefaultCategory(Member member) {
return new Category(null, member, DEFAULT_CATEGORY_NAME, true, DEFAULT_CATEGORY_ORDINAL);
}

public void validateAuthorization(Member member) {
if (!getMember().equals(member)) {
throw new CodeZapException(ErrorCode.FORBIDDEN_ACCESS, "해당 카테고리를 수정 또는 삭제할 권한이 없는 유저입니다.");
}
public boolean hasAuthorization(Member member) {
return this.member.equals(member);
}

public boolean isDefault() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package codezap.category.dto.request;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

import codezap.global.validation.ValidationGroups.NotNullGroup;
Expand All @@ -11,6 +13,11 @@ public record CreateCategoryRequest(
@Schema(description = "카테고리 이름", example = "Spring")
@NotBlank(message = "카테고리 이름이 null 입니다.", groups = NotNullGroup.class)
@Size(max = 15, message = "카테고리 이름은 최대 15자까지 입력 가능합니다.", groups = SizeCheckGroup.class)
String name
String name,

@Schema(description = "카테고리 순서", example = "1")
@NotNull(message = "카테고리 순서가 null 입니다.", groups = NotNullGroup.class)
@Min(value = 1, message = "카테고리의 순서는 1 이상이어야 합니다.")
Integer ordinal
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package codezap.category.dto.request;

import java.util.List;
import java.util.stream.Stream;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;

import codezap.category.dto.request.validation.ValidatedDuplicateNameRequest;
import codezap.category.dto.request.validation.ValidatedDuplicateIdRequest;
import codezap.global.validation.ValidationGroups.NotNullGroup;
import codezap.global.validation.ValidatedOrdinalRequest;
import io.swagger.v3.oas.annotations.media.Schema;

public record UpdateAllCategoriesRequest(
@Schema(description = "생성할 카테고리 목록")
@Valid
List<CreateCategoryRequest> createCategories,

@Schema(description = "수정할 카테고리 목록")
@Valid
List<UpdateCategoryRequest> updateCategories,

@Schema(description = "삭제할 카테고리 목록")
@NotNull(message = "삭제하는 카테고리 ID 목록이 null 입니다.", groups = NotNullGroup.class)
List<Long> deleteCategoryIds
) implements ValidatedOrdinalRequest, ValidatedDuplicateIdRequest, ValidatedDuplicateNameRequest {
@Override
public List<Integer> extractOrdinal() {
return Stream.concat(
createCategories.stream().map(CreateCategoryRequest::ordinal),
updateCategories.stream().map(UpdateCategoryRequest::ordinal)
).sorted().toList();
}

@Override
public List<Long> extractIds() {
return Stream.concat(
updateCategories.stream().map(UpdateCategoryRequest::id),
deleteCategoryIds.stream()
).toList();
}

@Override
public List<String> extractNames() {
return Stream.concat(
createCategories.stream().map(CreateCategoryRequest::name),
updateCategories.stream().map(UpdateCategoryRequest::name)
).toList();
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
package codezap.category.dto.request;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

import codezap.global.validation.ValidationGroups.NotNullGroup;
import codezap.global.validation.ValidationGroups.SizeCheckGroup;
import io.swagger.v3.oas.annotations.media.Schema;

public record UpdateCategoryRequest(
@Schema(description = "카테고리 ID", example = "1")
@NotNull(message = "카테고리 ID가 null 입니다.", groups = NotNullGroup.class)
Long id,

@Schema(description = "카테고리 이름", example = "Spring")
@NotBlank(message = "카테고리 이름이 null 입니다.", groups = NotNullGroup.class)
@Size(max = 15, message = "카테고리 이름은 최대 15자까지 입력 가능합니다.", groups = SizeCheckGroup.class)
String name
String name,

@Schema(description = "카테고리 순서", example = "1")
@NotNull(message = "카테고리 순서가 null 입니다.", groups = NotNullGroup.class)
@Min(value = 1, message = "카테고리의 순서는 1 이상이어야 합니다.")
Integer ordinal
) {
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package codezap.template.dto.request.validation;
package codezap.category.dto.request.validation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
Expand All @@ -10,8 +10,8 @@

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = SourceCodesOrdinalValidator.class)
public @interface SourceCodesOrdinal {
@Constraint(validatedBy = DuplicateIdValidator.class)
public @interface DuplicateId {

String message();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package codezap.category.dto.request.validation;

import java.util.HashSet;
import java.util.List;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class DuplicateIdValidator implements ConstraintValidator<DuplicateId, ValidatedDuplicateIdRequest> {

@Override
public boolean isValid(ValidatedDuplicateIdRequest request,
ConstraintValidatorContext constraintValidatorContext
) {
List<Long> ids = request.extractIds();
return ids.size() == new HashSet<>(ids).size();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package codezap.category.dto.request.validation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DuplicateNameValidator.class)
public @interface DuplicateName {

String message();

Class<?>[] groups() default {};

Class<? extends Payload>[] payload() default {};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package codezap.category.dto.request.validation;

import java.util.HashSet;
import java.util.List;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class DuplicateNameValidator implements ConstraintValidator<DuplicateName, ValidatedDuplicateNameRequest> {
@Override
public boolean isValid(ValidatedDuplicateNameRequest request,
ConstraintValidatorContext constraintValidatorContext
) {
List<String> names = request.extractNames();
return names.size() == new HashSet<>(names).size();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package codezap.category.dto.request.validation;

import java.util.List;

import codezap.global.validation.ValidationGroups.DuplicateIdGroup;

@DuplicateId(message = "id가 중복되었습니다.", groups = DuplicateIdGroup.class)
public interface ValidatedDuplicateIdRequest {

List<Long> extractIds();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package codezap.category.dto.request.validation;

import java.util.List;

import codezap.global.validation.ValidationGroups.DuplicateNameGroup;

@DuplicateName(message = "카테고리명이 중복되었습니다.", groups = DuplicateNameGroup.class)
public interface ValidatedDuplicateNameRequest {

List<String> extractNames();
}
Loading

0 comments on commit ac3848f

Please sign in to comment.