feat: add User entity, repository, service, and Flyway users table migration
- V1__create_users_table.sql replaces placeholder: creates users table with id UUID PK, email UNIQUE NOT NULL, password_hash NOT NULL, subscription VARCHAR(20) DEFAULT 'none' with CHECK constraint (none/basic/pro), created_at/updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP. Compatible with both H2 and PostgreSQL. - SecurityConfig: minimal @Configuration providing BCryptPasswordEncoder bean. Required because Spring Boot 4 no longer auto-configures a PasswordEncoder. - Subscription enum: NONE, BASIC, PRO with string values matching the DB CHECK constraint. - User entity: @PrePersist generates UUID and timestamps in application code, @PreUpdate refreshes updated_at. Email setter normalizes to lowercase for case-insensitive uniqueness. Explicit getters/setters (no Lombok per guidelines). - UserRepository: Spring Data JPA extending JpaRepository<User, UUID>. findByEmail(Optional) and existsByEmail for duplicate checks. - UserService: @RequiredArgsConstructor with constructor-injected UserRepository and PasswordEncoder. createUser normalizes email, checks duplicates via existsByEmail, throws EmailAlreadyExistsException, hashes password with BCrypt, saves. findByEmail returns Optional<User>. - EmailAlreadyExistsException: custom RuntimeException for duplicate registration attempts. ControllerAdvice handler deferred to auth ticket. Verification: ./gradlew test passes (Flyway + H2 context loads). docker compose up -d succeeds, Flyway applies V1 against PostgreSQL 16. \d users confirms all columns, constraints, defaults, and indexes.
This commit is contained in:
parent
4d449d54d0
commit
c03b5a1401
8 changed files with 201 additions and 2 deletions
|
|
@ -0,0 +1,15 @@
|
||||||
|
package se.bilhalsning.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public PasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package se.bilhalsning.entity;
|
||||||
|
|
||||||
|
public enum Subscription {
|
||||||
|
|
||||||
|
NONE("none"),
|
||||||
|
BASIC("basic"),
|
||||||
|
PRO("pro");
|
||||||
|
|
||||||
|
private final String value;
|
||||||
|
|
||||||
|
Subscription(String value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getValue() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
102
backend/src/main/java/se/bilhalsning/entity/User.java
Normal file
102
backend/src/main/java/se/bilhalsning/entity/User.java
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
package se.bilhalsning.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.PrePersist;
|
||||||
|
import jakarta.persistence.PreUpdate;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "users")
|
||||||
|
public class User {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "email", nullable = false, unique = true, length = 255)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Column(name = "password_hash", nullable = false, length = 255)
|
||||||
|
private String passwordHash;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "subscription", nullable = false, length = 20)
|
||||||
|
private Subscription subscription = Subscription.NONE;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private Instant updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
void onCreate() {
|
||||||
|
if (this.id == null) {
|
||||||
|
this.id = UUID.randomUUID();
|
||||||
|
}
|
||||||
|
Instant now = Instant.now();
|
||||||
|
if (this.createdAt == null) {
|
||||||
|
this.createdAt = now;
|
||||||
|
}
|
||||||
|
this.updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
void onUpdate() {
|
||||||
|
this.updatedAt = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(UUID id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEmail() {
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEmail(String email) {
|
||||||
|
this.email = email != null ? email.toLowerCase().trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPasswordHash() {
|
||||||
|
return passwordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPasswordHash(String passwordHash) {
|
||||||
|
this.passwordHash = passwordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Subscription getSubscription() {
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSubscription(Subscription subscription) {
|
||||||
|
this.subscription = subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(Instant createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getUpdatedAt() {
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUpdatedAt(Instant updatedAt) {
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package se.bilhalsning.exception;
|
||||||
|
|
||||||
|
public class EmailAlreadyExistsException extends RuntimeException {
|
||||||
|
|
||||||
|
public EmailAlreadyExistsException(String email) {
|
||||||
|
super("Email already registered: " + email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package se.bilhalsning.repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import se.bilhalsning.entity.User;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface UserRepository extends JpaRepository<User, UUID> {
|
||||||
|
|
||||||
|
Optional<User> findByEmail(String email);
|
||||||
|
|
||||||
|
boolean existsByEmail(String email);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package se.bilhalsning.service;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import se.bilhalsning.entity.User;
|
||||||
|
import se.bilhalsning.exception.EmailAlreadyExistsException;
|
||||||
|
import se.bilhalsning.repository.UserRepository;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
public Optional<User> findByEmail(String email) {
|
||||||
|
return userRepository.findByEmail(email.toLowerCase().trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
public User createUser(String email, String password) {
|
||||||
|
String normalizedEmail = email.toLowerCase().trim();
|
||||||
|
if (userRepository.existsByEmail(normalizedEmail)) {
|
||||||
|
throw new EmailAlreadyExistsException(normalizedEmail);
|
||||||
|
}
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail(normalizedEmail);
|
||||||
|
user.setPasswordHash(passwordEncoder.encode(password));
|
||||||
|
return userRepository.save(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID NOT NULL,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
subscription VARCHAR(20) NOT NULL DEFAULT 'none',
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_users PRIMARY KEY (id),
|
||||||
|
CONSTRAINT uq_users_email UNIQUE (email),
|
||||||
|
CONSTRAINT ck_users_subscription CHECK (subscription IN ('none', 'basic', 'pro'))
|
||||||
|
);
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
-- Initial schema: placeholder migration
|
|
||||||
-- Core tables will be added in subsequent migrations.
|
|
||||||
Loading…
Reference in a new issue