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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/operations/policies/ERD_DECISIONS_AND_LIFECYCLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
| `BlockUser` | hard delete | 차단 관계는 하드 삭제한다. |
| `Report` | preserve | 신고 데이터는 보존한다. |
| `ConsentItem` | preserve | 동의 항목 정의 데이터는 보존한다. |
| `UserConsent` | pending | 사용자별 terms consent 상태로 유지하며, retention/anonymization/deletion policy는 pending이다. |
| `UserConsent` | pending | 사용자별 동의 상태로 유지하며, retention/anonymization/deletion policy는 pending이다. |

## DailyJogak Creation Rules
- routine midnight batch는 오늘자 `DailyJogak` row를 생성한다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public void update(boolean agreed, LocalDateTime changedAt) {
this.withdrawnAt = null;
return;
}
this.agreedAt = null;
this.withdrawnAt = changedAt;
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package com.mogak.spring.repository;

import com.mogak.spring.domain.consent.ConsentItem;
import java.util.Collection;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ConsentItemRepository extends JpaRepository<ConsentItem, Long> {
List<ConsentItem> findAllByActiveTrueOrderByIdAsc();

List<ConsentItem> findAllByCodeInAndActiveTrue(Collection<String> codes);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.mogak.spring.repository;

import com.mogak.spring.domain.consent.UserConsent;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
Expand All @@ -9,4 +10,6 @@ public interface UserConsentRepository extends JpaRepository<UserConsent, Long>
Optional<UserConsent> findByUserIdAndConsentItemId(Long userId, Long consentItemId);

List<UserConsent> findAllByUserId(Long userId);

List<UserConsent> findAllByUserIdAndConsentItemCodeIn(Long userId, Collection<String> codes);
}
6 changes: 6 additions & 0 deletions src/main/java/com/mogak/spring/service/ConsentService.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.mogak.spring.service;

import com.mogak.spring.domain.user.User;
import com.mogak.spring.service.command.MarketingConsentCommand;
import com.mogak.spring.service.command.UserConsentCommand;
import com.mogak.spring.service.result.ConsentItemResult;
import com.mogak.spring.service.result.MarketingConsentResult;
import java.util.List;

public interface ConsentService {
Expand All @@ -11,4 +13,8 @@ public interface ConsentService {
void saveUserConsents(User user, List<UserConsentCommand> consents);

void updateUserConsents(Long userId, List<UserConsentCommand> consents);

MarketingConsentResult getMarketingConsent(Long userId);

MarketingConsentResult updateMarketingConsent(Long userId, MarketingConsentCommand command);
}
111 changes: 109 additions & 2 deletions src/main/java/com/mogak/spring/service/ConsentServiceImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@
import com.mogak.spring.repository.ConsentItemRepository;
import com.mogak.spring.repository.UserConsentRepository;
import com.mogak.spring.repository.UserRepository;
import com.mogak.spring.service.command.MarketingConsentCommand;
import com.mogak.spring.service.command.UserConsentCommand;
import com.mogak.spring.service.result.ConsentItemResult;
import com.mogak.spring.service.result.MarketingConsentResult;
import java.util.Collection;
import java.util.HashMap;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
Expand All @@ -24,6 +28,13 @@
@RequiredArgsConstructor
@Service
public class ConsentServiceImpl implements ConsentService {
private static final String MARKETING_CODE = "MARKETING";
private static final String ADVERTISEMENT_CODE = "ADVERTISEMENT";
private static final List<String> MARKETING_CONSENT_CODES = List.of(
MARKETING_CODE,
ADVERTISEMENT_CODE
);

private final ConsentItemRepository consentItemRepository;
private final UserConsentRepository userConsentRepository;
private final UserRepository userRepository;
Expand All @@ -42,6 +53,13 @@ public List<ConsentItemResult> getActiveConsentItems() {
.toList();
}

@Transactional(readOnly = true)
@Override
public MarketingConsentResult getMarketingConsent(Long userId) {
validateActiveUser(userId);
return getCurrentMarketingConsent(userId);
}

@Transactional
@Override
public void saveUserConsents(User user, List<UserConsentCommand> consents) {
Expand All @@ -54,14 +72,36 @@ public void saveUserConsents(User user, List<UserConsentCommand> consents) {
@Transactional
@Override
public void updateUserConsents(Long userId, List<UserConsentCommand> consents) {
User user = userRepository.findActiveById(userId)
.orElseThrow(() -> new BaseException(ErrorCode.NOT_EXIST_USER));
User user = validateActiveUser(userId);
if (consents == null || consents.isEmpty()) {
return;
}
upsertUserConsents(user, consents);
}

@Transactional
@Override
public MarketingConsentResult updateMarketingConsent(Long userId, MarketingConsentCommand command) {
if (command == null || command.isEmpty()) {
throw new BaseException(ErrorCode.INVALID_PARAMETER_ERROR);
}

User user = validateActiveUser(userId);
List<String> requestedCodes = requestedCodes(command);
Map<String, ConsentItem> consentItems = findActiveConsentItemsByCode(requestedCodes);
Map<String, UserConsent> userConsents = findUserConsentsByCode(userId, requestedCodes);
LocalDateTime now = LocalDateTime.now();

if (command.marketingAgreed() != null) {
updateMarketingConsent(user, command.marketingAgreed(), MARKETING_CODE, consentItems, userConsents, now);
}
if (command.advertisementAgreed() != null) {
updateMarketingConsent(user, command.advertisementAgreed(), ADVERTISEMENT_CODE, consentItems, userConsents, now);
}

return getCurrentMarketingConsent(userId);
}

private void upsertUserConsents(User user, List<UserConsentCommand> consents) {
validateConsentCommands(consents);
Map<Long, ConsentItem> consentItems = findConsentItems(consents);
Expand Down Expand Up @@ -110,4 +150,71 @@ private Map<Long, ConsentItem> findConsentItems(List<UserConsentCommand> consent
return consentItemRepository.findAllById(ids).stream()
.collect(Collectors.toMap(ConsentItem::getId, Function.identity()));
}

private User validateActiveUser(Long userId) {
return userRepository.findActiveById(userId)
.orElseThrow(() -> new BaseException(ErrorCode.NOT_EXIST_USER));
}

private MarketingConsentResult getCurrentMarketingConsent(Long userId) {
Map<String, UserConsent> userConsents = findUserConsentsByCode(userId, MARKETING_CONSENT_CODES);
return new MarketingConsentResult(
agreed(userConsents, MARKETING_CODE),
agreed(userConsents, ADVERTISEMENT_CODE)
);
}

private boolean agreed(Map<String, UserConsent> userConsents, String code) {
UserConsent userConsent = userConsents.get(code);
return userConsent != null && userConsent.isAgreed();
}

private List<String> requestedCodes(MarketingConsentCommand command) {
return MARKETING_CONSENT_CODES.stream()
.filter(code -> {
if (MARKETING_CODE.equals(code)) {
return command.marketingAgreed() != null;
}
if (ADVERTISEMENT_CODE.equals(code)) {
return command.advertisementAgreed() != null;
}
return false;
})
.toList();
}

private Map<String, ConsentItem> findActiveConsentItemsByCode(Collection<String> codes) {
Map<String, ConsentItem> consentItems = consentItemRepository.findAllByCodeInAndActiveTrue(codes).stream()
.collect(Collectors.toMap(ConsentItem::getCode, Function.identity()));
if (consentItems.size() != codes.size()) {
throw new BaseException(ErrorCode.NOT_EXIST_CONSENT_ITEM);
}
return consentItems;
}

private Map<String, UserConsent> findUserConsentsByCode(Long userId, Collection<String> codes) {
Map<String, UserConsent> userConsents = new HashMap<>();
userConsentRepository.findAllByUserIdAndConsentItemCodeIn(userId, codes)
.forEach(userConsent -> userConsents.put(userConsent.getConsentItem().getCode(), userConsent));
return userConsents;
}

private void updateMarketingConsent(
User user,
Boolean agreed,
String code,
Map<String, ConsentItem> consentItems,
Map<String, UserConsent> userConsents,
LocalDateTime now
) {
UserConsent userConsent = userConsents.get(code);
if (userConsent == null) {
userConsent = UserConsent.builder()
.user(user)
.consentItem(consentItems.get(code))
.build();
userConsentRepository.save(userConsent);
}
userConsent.update(agreed, now);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.mogak.spring.service.command;

public record MarketingConsentCommand(
Boolean marketingAgreed,
Boolean advertisementAgreed
) {
public boolean isEmpty() {
return marketingAgreed == null && advertisementAgreed == null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.mogak.spring.service.result;

public record MarketingConsentResult(
boolean marketingAgreed,
boolean advertisementAgreed
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@
import com.mogak.spring.global.ErrorCode;
import com.mogak.spring.jwt.AuthenticatedUser;
import com.mogak.spring.service.ConsentService;
import com.mogak.spring.service.result.MarketingConsentResult;
import com.mogak.spring.web.dto.consentdto.ConsentItemResponse;
import com.mogak.spring.web.dto.consentdto.MarketingConsentPatchRequest;
import com.mogak.spring.web.dto.consentdto.MarketingConsentResponse;
import com.mogak.spring.web.dto.consentdto.UserConsentUpdateRequest;
import jakarta.validation.Valid;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -30,6 +34,14 @@ public ResponseEntity<BaseResponse<List<ConsentItemResponse>>> getConsents() {
return ResponseEntity.ok(new BaseResponse<>(responses));
}

@GetMapping("/api/users/marketing-consent")
public ResponseEntity<BaseResponse<MarketingConsentResponse>> getMarketingConsent(
@AuthenticationPrincipal AuthenticatedUser authenticatedUser
) {
MarketingConsentResult result = consentService.getMarketingConsent(authenticatedUser.getUserId());
return ResponseEntity.ok(new BaseResponse<>(MarketingConsentResponse.from(result)));
}

@PutMapping("/api/users/consents")
public ResponseEntity<BaseResponse<ErrorCode>> updateUserConsents(
@AuthenticationPrincipal AuthenticatedUser authenticatedUser,
Expand All @@ -38,4 +50,16 @@ public ResponseEntity<BaseResponse<ErrorCode>> updateUserConsents(
consentService.updateUserConsents(authenticatedUser.getUserId(), request.toCommands());
return ResponseEntity.ok(new BaseResponse<>(ErrorCode.SUCCESS));
}

@PatchMapping("/api/users/marketing-consent")
public ResponseEntity<BaseResponse<MarketingConsentResponse>> updateMarketingConsent(
@AuthenticationPrincipal AuthenticatedUser authenticatedUser,
@Valid @RequestBody MarketingConsentPatchRequest request
) {
MarketingConsentResult result = consentService.updateMarketingConsent(
authenticatedUser.getUserId(),
request.toCommand()
);
return ResponseEntity.ok(new BaseResponse<>(MarketingConsentResponse.from(result)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.mogak.spring.web.dto.consentdto;

import com.mogak.spring.service.command.MarketingConsentCommand;
import jakarta.validation.constraints.AssertTrue;

public record MarketingConsentPatchRequest(
Boolean marketingAgreed,
Boolean advertisementAgreed
) {
@AssertTrue(message = "변경할 동의 값을 입력해주세요.")
public boolean hasAnyAgreement() {
return marketingAgreed != null || advertisementAgreed != null;
}

public MarketingConsentCommand toCommand() {
return new MarketingConsentCommand(marketingAgreed, advertisementAgreed);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.mogak.spring.web.dto.consentdto;

import com.mogak.spring.service.result.MarketingConsentResult;

public record MarketingConsentResponse(
boolean marketingAgreed,
boolean advertisementAgreed
) {
public static MarketingConsentResponse from(MarketingConsentResult result) {
return new MarketingConsentResponse(result.marketingAgreed(), result.advertisementAgreed());
}
}
19 changes: 19 additions & 0 deletions src/test/java/com/mogak/spring/config/SecurityConfigTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
Expand Down Expand Up @@ -204,6 +205,24 @@ void consentItemsEndpointIsPublic() throws Exception {
.andExpect(jsonPath("$.result[0].code").value("MARKETING"));
}

@Test
@DisplayName("광고/마케팅 동의 조회 API는 토큰 없이 호출하면 401을 반환한다")
void marketingConsentGetRejectsMissingToken() throws Exception {
mockMvc.perform(get("/api/users/marketing-consent"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("T003"));
}

@Test
@DisplayName("광고/마케팅 동의 변경 API는 토큰 없이 호출하면 401을 반환한다")
void marketingConsentPatchRejectsMissingToken() throws Exception {
mockMvc.perform(patch("/api/users/marketing-consent")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"marketingAgreed\":true}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("T003"));
}

private org.springframework.test.web.servlet.request.MockMultipartHttpServletRequestBuilder joinRequest(String role)
throws Exception {
MockMultipartFile request = new MockMultipartFile(
Expand Down
Loading
Loading