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
60 changes: 60 additions & 0 deletions src/main/java/com/fowoco/server/auth/api/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
Expand Down Expand Up @@ -361,4 +362,63 @@ public ResponseEntity<Void> logout(
public CurrentActorResponse me() {
return CurrentActorResponse.from(actorContextProvider.requireCurrentActor());
}

@Operation(
operationId = "getMyProfile",
summary = "내 프로필 조회",
description = "현재 로그인한 사용자의 표시 이름·연락처를 반환합니다.",
security = @SecurityRequirement(name = "bearerAuth")
)
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "현재 사용자 프로필",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ProfileResponse.class)
)
),
@ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized")
})
@GetMapping(path = "/me/profile", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')")
public ProfileResponse getMyProfile() {
var actor = actorContextProvider.requireCurrentActor();
return ProfileResponse.from(authService.currentProfile(actor.actorId(), actor.companyId()));
}

@Operation(
operationId = "updateMyProfile",
summary = "내 프로필 수정",
description = "현재 로그인한 사용자의 표시 이름·연락처를 수정합니다.",
security = @SecurityRequirement(name = "bearerAuth")
)
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "수정된 프로필",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ProfileResponse.class)
)
),
@ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"),
@ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"),
@ApiResponse(responseCode = "415", ref = "#/components/responses/UnsupportedMediaType")
})
@PatchMapping(
path = "/me/profile",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
@PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')")
public ProfileResponse updateMyProfile(@Valid @RequestBody UpdateProfileRequest request) {
var actor = actorContextProvider.requireCurrentActor();
return ProfileResponse.from(authService.updateProfile(
actor.actorId(),
actor.companyId(),
request.getDisplayName(),
request.getPhone()
));
}
}
19 changes: 19 additions & 0 deletions src/main/java/com/fowoco/server/auth/api/ProfileResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.fowoco.server.auth.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fowoco.server.auth.domain.UserAccount;
import io.swagger.v3.oas.annotations.media.Schema;

@Schema(name = "ProfileResponse", description = "현재 사용자의 개인 프로필")
public record ProfileResponse(
@JsonProperty("display_name")
@Schema(name = "display_name", description = "화면 표시 이름")
String displayName,
@Schema(description = "연락처 (선택 입력, 미등록 시 null)", example = "010-1234-5678")
String phone
) {

public static ProfileResponse from(UserAccount userAccount) {
return new ProfileResponse(userAccount.displayName(), userAccount.phone());
}
}
21 changes: 20 additions & 1 deletion src/main/java/com/fowoco/server/auth/api/SignupRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ public final class SignupRequest {
@Pattern(regexp = "^[^\\p{Cc}]+$", message = "담당자 이름에 제어 문자를 사용할 수 없습니다.")
private final String displayName;

@JsonProperty("phone")
@Schema(
description = "최초 담당자의 연락처 (선택)",
example = "010-1234-5678",
maxLength = 30,
requiredMode = Schema.RequiredMode.NOT_REQUIRED
)
@Size(max = 30, message = "연락처는 30자 이하여야 합니다.")
@Pattern(regexp = "^[0-9+()\\-\\s]*$", message = "연락처 형식이 올바르지 않습니다.")
private final String phone;

@Schema(
description = "로그인에 사용할 이메일. 앞뒤 공백 제거 후 소문자로 정규화합니다.",
example = "name@company.com",
Expand Down Expand Up @@ -76,12 +87,14 @@ public final class SignupRequest {
public SignupRequest(
@JsonProperty("company_name") String companyName,
@JsonProperty("display_name") String displayName,
@JsonProperty("phone") String phone,
@JsonProperty("email") String email,
@JsonProperty("password") String password,
@JsonProperty("agreements") SignupAgreementsRequest agreements
) {
this.companyName = stripNullable(companyName);
this.displayName = stripNullable(displayName);
this.phone = stripNullable(phone);
this.email = stripNullable(email);
this.password = password;
this.agreements = agreements;
Expand All @@ -95,6 +108,10 @@ public String getDisplayName() {
return displayName;
}

public String getPhone() {
return phone;
}

public String getEmail() {
return email;
}
Expand All @@ -108,7 +125,9 @@ public SignupAgreementsRequest getAgreements() {
}

public SignupCommand toCommand(String requestId) {
return new SignupCommand(companyName, displayName, email, password, agreements.toAgreements(), requestId);
return new SignupCommand(
companyName, displayName, phone, email, password, agreements.toAgreements(), requestId
);
}

private static String stripNullable(String value) {
Expand Down
52 changes: 52 additions & 0 deletions src/main/java/com/fowoco/server/auth/api/UpdateProfileRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.fowoco.server.auth.api;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

@Schema(name = "UpdateProfileRequest", description = "현재 사용자의 개인 프로필 수정 요청")
public final class UpdateProfileRequest {

@JsonProperty("display_name")
@Schema(
name = "display_name",
description = "화면 표시 이름",
example = "김경민",
maxLength = 80,
requiredMode = Schema.RequiredMode.REQUIRED
)
@NotBlank(message = "표시 이름을 입력해 주세요.")
@Size(max = 80, message = "표시 이름은 80자 이하여야 합니다.")
@Pattern(regexp = "^[^\\p{Cc}]+$", message = "표시 이름에 제어 문자를 사용할 수 없습니다.")
private final String displayName;

@Schema(
description = "연락처 (선택, 빈 문자열이면 삭제)",
example = "010-1234-5678",
maxLength = 30,
requiredMode = Schema.RequiredMode.NOT_REQUIRED
)
@Size(max = 30, message = "연락처는 30자 이하여야 합니다.")
@Pattern(regexp = "^[0-9+()\\-\\s]*$", message = "연락처 형식이 올바르지 않습니다.")
private final String phone;

@JsonCreator
public UpdateProfileRequest(
@JsonProperty("display_name") String displayName,
@JsonProperty("phone") String phone
) {
this.displayName = displayName == null ? null : displayName.strip();
this.phone = phone == null ? null : phone.strip();
}

public String getDisplayName() {
return displayName;
}

public String getPhone() {
return phone;
}
}
15 changes: 15 additions & 0 deletions src/main/java/com/fowoco/server/auth/application/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,21 @@ public RefreshResult refresh(String rawRefreshToken) {
return outcome.result().orElseThrow(InvalidRefreshTokenException::new);
}

@Transactional(readOnly = true)
public UserAccount currentProfile(UUID userId, UUID companyId) {
return userAccountRepository.findByUserIdAndCompanyId(userId, companyId)
.orElseThrow(() -> new IllegalStateException("authenticated user account was not found"));
}

@Transactional
public UserAccount updateProfile(UUID userId, UUID companyId, String displayName, String phone) {
UserAccount current = userAccountRepository.findByUserIdAndCompanyIdWithLock(userId, companyId)
.orElseThrow(() -> new IllegalStateException("authenticated user account was not found"));
UserAccount updated = current.updateProfile(displayName, phone, clock.instant());
userAccountRepository.update(updated);
return updated;
}

public void logout(String rawRefreshToken) {
if (!RefreshTokenFormat.isValidRawValue(rawRefreshToken)) {
authAuditPort.record(AuthAuditEvent.anonymous(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
public record SignupCommand(
String companyName,
String displayName,
String phone,
String email,
String password,
SignupAgreements agreements,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ public SignupResult signup(SignupCommand command) {
uuidGenerator.generate(),
company.companyId(),
command.displayName(),
command.phone(),
command.email(),
passwordHasher.hash(command.password()),
UserRole.ADMIN,
Expand Down
49 changes: 49 additions & 0 deletions src/main/java/com/fowoco/server/auth/domain/UserAccount.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ public final class UserAccount {
private static final int MAX_EMAIL_LENGTH = 254;
private static final int MAX_DISPLAY_NAME_LENGTH = 80;
private static final int MAX_PASSWORD_HASH_LENGTH = 255;
private static final int MAX_PHONE_LENGTH = 30;

private final UUID userId;
private final UUID companyId;
private final String displayName;
private final String phone;
private final String email;
private final String normalizedEmail;
private final String passwordHash;
Expand All @@ -27,6 +29,7 @@ public UserAccount(
UUID userId,
UUID companyId,
String displayName,
String phone,
String email,
String normalizedEmail,
String passwordHash,
Expand All @@ -39,6 +42,7 @@ public UserAccount(
this.userId = Objects.requireNonNull(userId, "userId must not be null");
this.companyId = Objects.requireNonNull(companyId, "companyId must not be null");
this.displayName = requireDisplayName(displayName);
this.phone = requirePhone(phone);
this.email = requireEmail(email);
String expectedNormalizedEmail = normalizeEmail(this.email);
if (!expectedNormalizedEmail.equals(normalizedEmail)) {
Expand All @@ -63,6 +67,7 @@ public static UserAccount create(
UUID userId,
UUID companyId,
String displayName,
String phone,
String email,
String passwordHash,
UserRole role,
Expand All @@ -73,6 +78,7 @@ public static UserAccount create(
userId,
companyId,
displayName,
phone,
email,
normalizeEmail(email),
passwordHash,
Expand Down Expand Up @@ -105,6 +111,7 @@ public UserAccount changePassword(String newPasswordHash, Instant now) {
userId,
companyId,
displayName,
phone,
email,
normalizedEmail,
newPasswordHash,
Expand All @@ -116,6 +123,27 @@ public UserAccount changePassword(String newPasswordHash, Instant now) {
);
}

public UserAccount updateProfile(String newDisplayName, String newPhone, Instant now) {
Objects.requireNonNull(now, "now must not be null");
if (now.isBefore(updatedAt)) {
throw new IllegalArgumentException("now must not be before updatedAt");
}
return new UserAccount(
userId,
companyId,
newDisplayName,
newPhone,
email,
normalizedEmail,
passwordHash,
role,
status,
createdAt,
now,
version + 1
);
}

public UUID userId() {
return userId;
}
Expand All @@ -128,6 +156,10 @@ public String displayName() {
return displayName;
}

public String phone() {
return phone;
}

public String email() {
return email;
}
Expand Down Expand Up @@ -187,6 +219,23 @@ private static String requireDisplayName(String displayName) {
return normalized;
}

private static String requirePhone(String phone) {
if (phone == null) {
return null;
}
String stripped = phone.strip();
if (stripped.isEmpty()) {
return null;
}
if (stripped.length() > MAX_PHONE_LENGTH) {
throw new IllegalArgumentException("phone must not exceed " + MAX_PHONE_LENGTH + " characters");
}
if (!stripped.matches("^[0-9+()\\-\\s]+$")) {
throw new IllegalArgumentException("phone format is invalid");
}
return stripped;
}

private static String requirePasswordHash(String passwordHash) {
if (passwordHash == null || passwordHash.isBlank()) {
throw new IllegalArgumentException("passwordHash must not be blank");
Expand Down
Loading
Loading