UserService.java

package com.hwhub.backend.application.service;

import com.hwhub.backend.application.service.oauth.GoogleOAuthService;
import com.hwhub.backend.domain.enums.AuthProvider;
import com.hwhub.backend.domain.enums.NotificationGroup;
import com.hwhub.backend.domain.enums.ProgramType;
import com.hwhub.backend.domain.enums.ThemeMode;
import com.hwhub.backend.domain.model.HouseholdModel;
import com.hwhub.backend.domain.model.UserModel;
import com.hwhub.backend.domain.oauth.google.GoogleUserInfo;
import com.hwhub.backend.domain.repository.HouseholdMemberRepository;
import com.hwhub.backend.domain.repository.UserNotificationSettingRepository;
import com.hwhub.backend.domain.repository.UserRepository;
import com.hwhub.backend.presentation.rest.common.CurrentPasswordInvalidException;
import com.hwhub.backend.presentation.rest.common.GoogleAccountAlreadyLinkedException;
import com.hwhub.backend.presentation.rest.common.GoogleSubAlreadyUsedException;
import com.hwhub.backend.presentation.rest.common.PasswordSameAsOldException;
import com.hwhub.backend.presentation.rest.common.ResourceNotFoundException;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class UserService {

  private final UserRepository userRepository;
  private final UserNotificationSettingRepository settingRepository;
  private final UserIconService userIconService;
  private final HouseholdMemberRepository householdMemberRepository;
  private final PasswordEncoder passwordEncoder;
  private final GoogleOAuthService googleOAuthService;

  public List<HouseholdModel> getHouseholds(Long userId) {
    return userRepository.findHouseholdsByUserId(userId);
  }

  @Transactional(readOnly = true)
  public UserModel getProfile(Long userId) {
    UserModel model =
        userRepository
            .findById(userId)
            .orElseThrow(() -> new ResourceNotFoundException("User not found. userId=" + userId));
    model.setIconUrl(userIconService.getIconUrl(model.getProfileImageKey()));
    return model;
  }

  @Transactional
  public UserModel updateProfile(Long userId, String displayName, String locale) {
    UserModel user =
        userRepository
            .findById(userId)
            .orElseThrow(() -> new ResourceNotFoundException("User not found. userId=" + userId));

    user.changeProfile(displayName, locale);
    userRepository.updateForEnduser(user, userId, ProgramType.ONL_USR.getCode());

    user.setIconUrl(userIconService.getIconUrl(user.getProfileImageKey()));

    return user;
  }

  @Transactional
  public void deleteAccount(Long userId) {
    // 所属世帯のチェック
    List<HouseholdModel> households = userRepository.findHouseholdsByUserId(userId);
    for (HouseholdModel h : households) {
      // 自分がOWNERの場合
      if (h.isOwner(userId)) {
        // メンバー数をチェック
        List<com.hwhub.backend.domain.model.HouseholdMemberModel> members =
            householdMemberRepository.findActiveByHouseholdId(h.getHouseholdId());
        if (members.size() > 1) {
          // 自分以外にもメンバーがいる場合は退会不可
          throw new IllegalArgumentException(
              "Cannot delete account because you are the owner of household '"
                  + h.getName()
                  + "' which has other members. Please transfer ownership or remove members first.");
        }
        // メンバーが自分のみならOK(この世帯は後日バッチ削除される)
      }
    }

    // ユーザーを非活性化 (論理削除)
    userRepository.deactivate(userId, ProgramType.ONL_USR.getCode());

    // 世帯メンバーから物理削除
    householdMemberRepository.deleteByUserId(userId);
  }

  /**
   * ログイン中ユーザのパスワードを変更する。
   *
   * <p>重要: passwordChangedAt を更新して、既存JWTを(後段の検証で)無効化できるようにする。
   */
  @Transactional
  public void changePassword(Long userId, String currentPassword, String newPassword) {
    UserModel user =
        userRepository
            .findById(userId)
            .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));

    // 現在パスワード一致チェック
    if (!passwordEncoder.matches(currentPassword, user.getPasswordHash())) {
      throw new CurrentPasswordInvalidException();
    }

    // 同一パスワード禁止
    if (passwordEncoder.matches(newPassword, user.getPasswordHash())) {
      throw new PasswordSameAsOldException();
    }

    // パスワード更新
    String newHash = passwordEncoder.encode(newPassword);
    LocalDateTime now = LocalDateTime.now();
    user.changePasswordHash(newHash, now);
    userRepository.updatePassword(user, userId, ProgramType.ONL_USR.getCode());
  }

  @Transactional
  public void linkGoogleAccount(Long loginUserId, String code) {
    // code -> token -> userinfo
    var token = googleOAuthService.exchangeCodeForToken(code);
    GoogleUserInfo info = googleOAuthService.fetchUserInfo(token.getAccessToken());

    // すでにこのログインユーザーが GOOGLE 連携済みなら弾く
    var user = userRepository.findById(loginUserId).orElseThrow();
    if (AuthProvider.GOOGLE.getCode().equals(user.getAuthProvider())
        && user.getAuthProviderId() != null) {
      throw new GoogleAccountAlreadyLinkedException();
    }

    // sub の重複チェック(別ユーザーがこのsubを使ってたらNG)
    userRepository
        .findByAuthProviderAndAuthProviderId(AuthProvider.GOOGLE.getCode(), info.getSub())
        .ifPresent(
            existingUser -> {
              if (!existingUser.getUserId().equals(loginUserId)) {
                throw new GoogleSubAlreadyUsedException();
              }
            });

    // password_hash を NULL に更新=以後パスワードログイン不可
    String displayName =
        info.getName() != null && !info.getName().isBlank()
            ? info.getName()
            : user.getDisplayName();

    userRepository.linkGoogleAccount(
        loginUserId, info.getSub(), info.getEmail(), displayName, ProgramType.ONL_USR.getCode());
  }

  @Transactional(readOnly = true)
  public NotificationSettingsResult getSettings(Long userId) {

    boolean notificationEnabled = userRepository.isNotificationEnabled(userId);

    Map<NotificationGroup, Boolean> merged = buildNotificationGroupMap(userId, notificationEnabled);

    return new NotificationSettingsResult(notificationEnabled, merged);
  }

  @Transactional
  public NotificationSettingsResult updateNotificationEnabled(
      Long userId, boolean notificationEnabled, Map<NotificationGroup, Boolean> groupSettings) {

    userRepository.updateNotificationEnabled(
        userId, notificationEnabled, userId, ProgramType.ONL_USR.getCode());

    if (notificationEnabled) {
      for (var e : groupSettings.entrySet()) {
        NotificationGroup group = e.getKey();
        Boolean enabled = e.getValue();

        if (enabled == null) continue;

        if (enabled) {
          // 差分を削除
          settingRepository.delete(userId, group);
        } else {
          // 差分を登録
          settingRepository.upsert(userId, group, false, userId, ProgramType.ONL_USR.getCode());
        }
      }
    }

    Map<NotificationGroup, Boolean> merged = buildNotificationGroupMap(userId, notificationEnabled);

    return new NotificationSettingsResult(notificationEnabled, merged);
  }

  /**
   * NotificationGroupを全列挙し、指定されたユーザの設定値を含めて返却する。
   *
   * @param userId ユーザID
   * @param notificationEnabled 通知有効フラグ
   * @return NotificationGroupと設定値のマップ
   */
  private Map<NotificationGroup, Boolean> buildNotificationGroupMap(
      Long userId, boolean notificationEnabled) {
    Map<NotificationGroup, Boolean> map = new LinkedHashMap<>();
    for (NotificationGroup g : NotificationGroup.values()) {
      boolean gEnabled =
          notificationEnabled && settingRepository.findEnabled(userId, g).orElse(true);
      map.put(g, gEnabled);
    }
    return map;
  }

  /**
   * テーマモードを更新する。
   *
   * @param userId ユーザID
   * @param themeMode 新しいテーマモード
   */
  @Transactional
  public void updateThemeMode(Long userId, ThemeMode themeMode) {
    UserModel user =
        userRepository
            .findById(userId)
            .orElseThrow(() -> new ResourceNotFoundException("User not found. userId=" + userId));

    user.changeThemeMode(themeMode);
    userRepository.updateThemeMode(user, userId, ProgramType.ONL_USR.getCode());
  }

  public record NotificationSettingsResult(
      boolean notificationEnabled, Map<NotificationGroup, Boolean> groupSettings) {}
}