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