feat: add user selection flow
This commit is contained in:
5
backend/src/main/java/se/rubble/hemhub/api/ApiError.java
Normal file
5
backend/src/main/java/se/rubble/hemhub/api/ApiError.java
Normal file
@ -0,0 +1,5 @@
|
||||
package se.rubble.hemhub.api;
|
||||
|
||||
public record ApiError(String code, String message) {
|
||||
}
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
package se.rubble.hemhub.api;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import se.rubble.hemhub.user.InvalidUserNameException;
|
||||
import se.rubble.hemhub.user.UserNameAlreadyExistsException;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class ApiExceptionHandler {
|
||||
|
||||
@ExceptionHandler(InvalidUserNameException.class)
|
||||
public ResponseEntity<ApiError> handleInvalidUserName() {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ApiError(
|
||||
"INVALID_USER_NAME",
|
||||
"Namnet måste innehålla mellan 1 och 50 tecken."));
|
||||
}
|
||||
|
||||
@ExceptionHandler(UserNameAlreadyExistsException.class)
|
||||
public ResponseEntity<ApiError> handleDuplicateUserName() {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(new ApiError(
|
||||
"USER_NAME_ALREADY_EXISTS",
|
||||
"En användare med det namnet finns redan."));
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
package se.rubble.hemhub.user;
|
||||
|
||||
public record CreateUserRequest(String name) {
|
||||
}
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
package se.rubble.hemhub.user;
|
||||
|
||||
public class InvalidUserNameException extends RuntimeException {
|
||||
}
|
||||
|
||||
48
backend/src/main/java/se/rubble/hemhub/user/User.java
Normal file
48
backend/src/main/java/se/rubble/hemhub/user/User.java
Normal file
@ -0,0 +1,48 @@
|
||||
package se.rubble.hemhub.user;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
@Entity
|
||||
@Table(name = "app_user")
|
||||
class User {
|
||||
|
||||
@Id
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false, length = 50)
|
||||
private String name;
|
||||
|
||||
@Column(name = "normalized_name", nullable = false, length = 150, unique = true)
|
||||
private String normalizedName;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
protected User() {
|
||||
}
|
||||
|
||||
User(UUID id, String name, String normalizedName, Instant createdAt) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.normalizedName = normalizedName;
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package se.rubble.hemhub.user;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
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.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
public class UserController {
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
UserController(UserService userService) {
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<UserResponse> findAll() {
|
||||
return userService.findAll();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public UserResponse create(@RequestBody(required = false) CreateUserRequest request) {
|
||||
return userService.create(request == null ? null : request.name());
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
package se.rubble.hemhub.user;
|
||||
|
||||
public class UserNameAlreadyExistsException extends RuntimeException {
|
||||
}
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
package se.rubble.hemhub.user;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
interface UserRepository extends JpaRepository<User, UUID> {
|
||||
|
||||
boolean existsByNormalizedName(String normalizedName);
|
||||
}
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
package se.rubble.hemhub.user;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record UserResponse(UUID id, String name, Instant createdAt) {
|
||||
|
||||
static UserResponse from(User user) {
|
||||
return new UserResponse(user.getId(), user.getName(), user.getCreatedAt());
|
||||
}
|
||||
}
|
||||
|
||||
66
backend/src/main/java/se/rubble/hemhub/user/UserService.java
Normal file
66
backend/src/main/java/se/rubble/hemhub/user/UserService.java
Normal file
@ -0,0 +1,66 @@
|
||||
package se.rubble.hemhub.user;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
class UserService {
|
||||
|
||||
private static final Comparator<User> BY_DISPLAY_NAME =
|
||||
Comparator.comparing(User::getName, String.CASE_INSENSITIVE_ORDER)
|
||||
.thenComparing(User::getName)
|
||||
.thenComparing(User::getId);
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final Clock clock;
|
||||
|
||||
@Autowired
|
||||
UserService(UserRepository userRepository) {
|
||||
this(userRepository, Clock.systemUTC());
|
||||
}
|
||||
|
||||
UserService(UserRepository userRepository, Clock clock) {
|
||||
this.userRepository = userRepository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
List<UserResponse> findAll() {
|
||||
return userRepository.findAll().stream()
|
||||
.sorted(BY_DISPLAY_NAME)
|
||||
.map(UserResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
UserResponse create(String requestedName) {
|
||||
String name = requestedName == null ? "" : requestedName.trim();
|
||||
|
||||
if (name.isEmpty() || name.codePointCount(0, name.length()) > 50) {
|
||||
throw new InvalidUserNameException();
|
||||
}
|
||||
|
||||
String normalizedName = name.toLowerCase(Locale.ROOT);
|
||||
|
||||
if (userRepository.existsByNormalizedName(normalizedName)) {
|
||||
throw new UserNameAlreadyExistsException();
|
||||
}
|
||||
|
||||
User user = new User(UUID.randomUUID(), name, normalizedName, Instant.now(clock));
|
||||
|
||||
try {
|
||||
return UserResponse.from(userRepository.saveAndFlush(user));
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new UserNameAlreadyExistsException();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user