fix: change db schema

This commit is contained in:
2026-08-22 08:26:39 +07:00
parent 99e7ff0a13
commit 3d22e0dfc0
50 changed files with 86 additions and 33 deletions
@@ -26,12 +26,12 @@ public class AuthController {
@PostMapping("/register")
@ResponseStatus(HttpStatus.CREATED)
public AuthResponse register(@Valid @RequestBody RegisterRequest request) {
return authService.register(request.email(), request.password(), request.displayName());
return authService.register(request.username(), request.email(), request.password(), request.displayName());
}
@PostMapping("/login")
public AuthResponse login(@Valid @RequestBody LoginRequest request) {
return authService.login(request.email(), request.password());
return authService.login(request.username(), request.password());
}
@PostMapping("/refresh")
@@ -1,10 +1,10 @@
package aplp.backend.web.identity.application.dtos;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record LoginRequest(
@NotBlank @Email(message = "must be a valid email") String email,
@NotBlank @Size(min = 3, max = 50) String username,
@NotBlank String password
) {
}
@@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record RegisterRequest(
@NotBlank @Size(min = 3, max = 50) String username,
@NotBlank @Email(message = "must be a valid email") @Size(max = 320) String email,
@NotBlank @Size(min = 8, max = 128, message = "must be between 8 and 128 characters") String password,
@NotBlank @Size(max = 100) String displayName
@@ -9,6 +9,7 @@ import aplp.backend.web.identity.application.dtos.AuthResponse;
import aplp.backend.web.identity.application.mappers.AuthMapper;
import aplp.backend.web.identity.domain.exceptions.EmailAlreadyExistsException;
import aplp.backend.web.identity.domain.exceptions.InvalidCredentialsException;
import aplp.backend.web.identity.domain.exceptions.UsernameAlreadyExistsException;
import aplp.backend.web.identity.domain.entities.RefreshToken;
import aplp.backend.web.identity.domain.exceptions.RefreshTokenInvalidException;
import aplp.backend.web.identity.domain.repositories.RefreshTokenRepository;
@@ -53,22 +54,24 @@ public class AuthService {
}
@Transactional
public AuthResponse register(String email, String rawPassword, String displayName) {
public AuthResponse register(String username, String email, String rawPassword, String displayName) {
Instant now = clock.instant();
String normalizedEmail = email.trim().toLowerCase();
if (userRepository.existsByEmail(normalizedEmail)) {
throw new EmailAlreadyExistsException(normalizedEmail);
}
User user = User.register(normalizedEmail, passwordEncoder.encode(rawPassword), displayName, now);
if (userRepository.existsByUsername(username)) {
throw new UsernameAlreadyExistsException(username);
}
User user = User.register(username, normalizedEmail, passwordEncoder.encode(rawPassword), displayName, now);
User saved = userRepository.save(user);
learnerProvisioner.provision(saved.id(), displayName);
return issueTokens(saved);
}
@Transactional
public AuthResponse login(String email, String rawPassword) {
String normalizedEmail = email.trim().toLowerCase();
User user = userRepository.findByEmail(normalizedEmail).orElseThrow(InvalidCredentialsException::new);
public AuthResponse login(String username, String rawPassword) {
User user = userRepository.findByUsername(username).orElseThrow(InvalidCredentialsException::new);
if (!passwordEncoder.matches(rawPassword, user.passwordHash())) {
throw new InvalidCredentialsException();
}
@@ -21,6 +21,9 @@ public class User {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String username;
@Column(nullable = false, unique = true, length = 320)
private String email;
@@ -43,9 +46,10 @@ public class User {
protected User() {
}
private User(Long id, String email, String passwordHash, String displayName,
private User(Long id, String username, String email, String passwordHash, String displayName,
UserStatus status, Instant createdAt, Instant updatedAt) {
this.id = id;
this.username = username;
this.email = email;
this.passwordHash = passwordHash;
this.displayName = displayName;
@@ -54,13 +58,13 @@ public class User {
this.updatedAt = updatedAt;
}
public static User register(String email, String passwordHash, String displayName, Instant now) {
return new User(null, normalizeEmail(email), passwordHash, displayName, UserStatus.ACTIVE, now, now);
public static User register(String username, String email, String passwordHash, String displayName, Instant now) {
return new User(null, username, normalizeEmail(email), passwordHash, displayName, UserStatus.ACTIVE, now, now);
}
public static User reconstruct(Long id, String email, String passwordHash, String displayName,
public static User reconstruct(Long id, String username, String email, String passwordHash, String displayName,
UserStatus status, Instant createdAt, Instant updatedAt) {
return new User(id, email, passwordHash, displayName, status, createdAt, updatedAt);
return new User(id, username, email, passwordHash, displayName, status, createdAt, updatedAt);
}
public void updatePasswordHash(String newHash, Instant now) {
@@ -81,6 +85,10 @@ public class User {
return id;
}
public String username() {
return username;
}
public String email() {
return email;
}
@@ -0,0 +1,11 @@
package aplp.backend.web.identity.domain.exceptions;
import aplp.backend.core.common.exception.DomainException;
import aplp.backend.core.common.exception.ErrorCode;
public class UsernameAlreadyExistsException extends DomainException {
public UsernameAlreadyExistsException(String username) {
super(ErrorCode.USERNAME_ALREADY_EXISTS, "Username is already taken: " + username);
}
}
@@ -8,9 +8,13 @@ public interface UserRepository {
Optional<User> findByEmail(String email);
Optional<User> findByUsername(String username);
Optional<User> findById(Long id);
boolean existsByEmail(String email);
boolean existsByUsername(String username);
User save(User user);
}
@@ -7,5 +7,9 @@ public interface UserJpaRepository extends JpaRepository<User, Long> {
boolean existsByEmail(String email);
boolean existsByUsername(String username);
java.util.Optional<User> findByEmail(String email);
java.util.Optional<User> findByUsername(String username);
}
@@ -20,6 +20,11 @@ public class UserRepositoryImpl implements UserRepository {
return jpaRepository.findByEmail(email);
}
@Override
public Optional<User> findByUsername(String username) {
return jpaRepository.findByUsername(username);
}
@Override
public Optional<User> findById(Long id) {
return jpaRepository.findById(id);
@@ -30,6 +35,11 @@ public class UserRepositoryImpl implements UserRepository {
return jpaRepository.existsByEmail(email);
}
@Override
public boolean existsByUsername(String username) {
return jpaRepository.existsByUsername(username);
}
@Override
public User save(User user) {
return jpaRepository.save(user);
+1 -1
View File
@@ -1,6 +1,6 @@
spring:
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/aplp}
url: ${DB_URL:jdbc:postgresql://192.168.2.100:5432/aplp}
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:Pa55w0rd}
hikari:
+5
View File
@@ -6,10 +6,15 @@ spring:
jpa:
hibernate:
ddl-auto: validate
properties:
hibernate:
default_schema: web
open-in-view: false
flyway:
enabled: true
locations: classpath:db/migration
schemas: web
default-schema: web
jackson:
default-property-inclusion: non_null
@@ -1,12 +1,14 @@
CREATE TABLE users (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
email VARCHAR(320) NOT NULL,
username VARCHAR(50) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_users_email UNIQUE (email)
CONSTRAINT uq_users_email UNIQUE (email),
CONSTRAINT uq_users_username UNIQUE (username)
);
CREATE TABLE refresh_token (
@@ -32,3 +34,4 @@ CREATE TABLE learner (
CREATE INDEX idx_refresh_token_user_id ON refresh_token (user_id);
CREATE INDEX idx_users_email ON users (email);
CREATE INDEX idx_users_username ON users (username);
@@ -38,10 +38,10 @@ class AuthFlowIntegrationTest {
@Test
void fullAuthFlow() throws Exception {
TokenPair registered = register(EMAIL, PASSWORD, "Learner One");
TokenPair registered = register("learnerone", EMAIL, PASSWORD, "Learner One");
viewMyProfile(registered.accessToken());
TokenPair loggedIn = login(EMAIL, PASSWORD);
TokenPair loggedIn = login("learnerone", PASSWORD);
viewMyProfile(loggedIn.accessToken());
TokenPair refreshed = refresh(loggedIn.refreshToken());
@@ -54,7 +54,7 @@ class AuthFlowIntegrationTest {
void registrationSetsXRequestIdAndRejectsDuplicate() throws Exception {
MvcResult first = mockMvc.perform(post("/api/v1/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(json("email", "dup@aplp.dev", "password", PASSWORD, "displayName", "Dup")))
.content(json("username", "dupuser", "email", "dup@aplp.dev", "password", PASSWORD, "displayName", "Dup")))
.andExpect(status().isCreated())
.andExpect(header().exists("X-Request-Id"))
.andReturn();
@@ -62,7 +62,7 @@ class AuthFlowIntegrationTest {
mockMvc.perform(post("/api/v1/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(json("email", "dup@aplp.dev", "password", PASSWORD, "displayName", "Dup")))
.content(json("username", "dupuser2", "email", "dup@aplp.dev", "password", PASSWORD, "displayName", "Dup")))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("EMAIL_ALREADY_EXISTS"));
}
@@ -80,10 +80,10 @@ class AuthFlowIntegrationTest {
@Test
void loginWithWrongPasswordIsRejected() throws Exception {
register("wrong@aplp.dev", PASSWORD, "Wrong");
register("wronguser", "wrong@aplp.dev", PASSWORD, "Wrong");
mockMvc.perform(post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(json("email", "wrong@aplp.dev", "password", "not-the-password")))
.content(json("username", "wronguser", "password", "not-the-password")))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("INVALID_CREDENTIALS"));
}
@@ -92,14 +92,14 @@ class AuthFlowIntegrationTest {
void validationErrorsReturnUniformShape() throws Exception {
mockMvc.perform(post("/api/v1/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"not-an-email\",\"password\":\"x\",\"displayName\":\"\"}"))
.content("{\"username\":\"\",\"email\":\"not-an-email\",\"password\":\"x\",\"displayName\":\"\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
}
@Test
void updateLearnerDisplayName() throws Exception {
TokenPair pair = register("update-me@aplp.dev", PASSWORD, "Old Name");
TokenPair pair = register("updateme", "update-me@aplp.dev", PASSWORD, "Old Name");
mockMvc.perform(patch("/api/v1/learners/me")
.header("Authorization", "Bearer " + pair.accessToken())
.contentType(MediaType.APPLICATION_JSON)
@@ -110,7 +110,7 @@ class AuthFlowIntegrationTest {
@Test
void refreshWithRevokedTokenIsRejected() throws Exception {
TokenPair pair = loginOrRegister("revoked@aplp.dev");
TokenPair pair = loginOrRegister("revokeduser", "revoked@aplp.dev");
mockMvc.perform(post("/api/v1/auth/logout")
.header("Authorization", "Bearer " + pair.accessToken())
@@ -141,10 +141,10 @@ class AuthFlowIntegrationTest {
.andExpect(jsonPath("$.status").value("UP"));
}
private TokenPair register(String email, String password, String displayName) throws Exception {
private TokenPair register(String username, String email, String password, String displayName) throws Exception {
MvcResult result = mockMvc.perform(post("/api/v1/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(json("email", email, "password", password, "displayName", displayName)))
.content(json("username", username, "email", email, "password", password, "displayName", displayName)))
.andExpect(status().isCreated())
.andReturn();
JsonNode body = bodyOf(result);
@@ -152,22 +152,21 @@ class AuthFlowIntegrationTest {
return new TokenPair(body.get("accessToken").asText(), body.get("refreshToken").asText());
}
private TokenPair login(String email, String password) throws Exception {
private TokenPair login(String username, String password) throws Exception {
MvcResult result = mockMvc.perform(post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(json("email", email, "password", password)))
.content(json("username", username, "password", password)))
.andExpect(status().isOk())
.andReturn();
JsonNode body = bodyOf(result);
assertThat(body.get("user").get("email").asText()).isEqualTo(email);
return new TokenPair(body.get("accessToken").asText(), body.get("refreshToken").asText());
}
private TokenPair loginOrRegister(String email) throws Exception {
private TokenPair loginOrRegister(String username, String email) throws Exception {
try {
return register(email, PASSWORD, "Revoked");
return register(username, email, PASSWORD, "Revoked");
} catch (AssertionError ignored) {
return login(email, PASSWORD);
return login(username, PASSWORD);
}
}
+5
View File
@@ -7,8 +7,13 @@ spring:
jpa:
hibernate:
ddl-auto: validate
properties:
hibernate:
default_schema: web
flyway:
enabled: true
schemas: web
default-schema: web
app:
jwt: