From 2e7033791ac722482198c64d1e5974f5e895ab4d Mon Sep 17 00:00:00 2001 From: sBubshait Date: Thu, 7 Aug 2025 03:37:09 +0300 Subject: [PATCH] feat: enable Registration through invitation codes --- .../wesal/wesal/config/SecurityConfig.java | 2 +- .../wesal/controller/AuthController.java | 75 +++++++++++++++++++ .../wesal/dto/CheckInvitationRequest.java | 23 ++++++ .../wesal/wesal/dto/RegisterRequest.java | 51 +++++++++++++ .../wesal/wesal/dto/RegisterResponse.java | 32 ++++++++ .../wesal/wesal/entity/InvitationCode.java | 72 ++++++++++++++++++ .../java/online/wesal/wesal/entity/User.java | 8 +- .../repository/InvitationCodeRepository.java | 19 +++++ .../wesal/service/InvitationCodeService.java | 69 +++++++++++++++++ 9 files changed, 346 insertions(+), 5 deletions(-) create mode 100644 backend/src/main/java/online/wesal/wesal/dto/CheckInvitationRequest.java create mode 100644 backend/src/main/java/online/wesal/wesal/dto/RegisterRequest.java create mode 100644 backend/src/main/java/online/wesal/wesal/dto/RegisterResponse.java create mode 100644 backend/src/main/java/online/wesal/wesal/entity/InvitationCode.java create mode 100644 backend/src/main/java/online/wesal/wesal/repository/InvitationCodeRepository.java create mode 100644 backend/src/main/java/online/wesal/wesal/service/InvitationCodeService.java diff --git a/backend/src/main/java/online/wesal/wesal/config/SecurityConfig.java b/backend/src/main/java/online/wesal/wesal/config/SecurityConfig.java index 7ccb541..5fd7df9 100644 --- a/backend/src/main/java/online/wesal/wesal/config/SecurityConfig.java +++ b/backend/src/main/java/online/wesal/wesal/config/SecurityConfig.java @@ -55,7 +55,7 @@ public class SecurityConfig { .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/", "/login", "/swagger-ui/**", "/v3/api-docs/**", "/docs/**", "/docs").permitAll() + .requestMatchers("/", "/login", "/checkInvitation", "/register", "/swagger-ui/**", "/v3/api-docs/**", "/docs/**", "/docs").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) diff --git a/backend/src/main/java/online/wesal/wesal/controller/AuthController.java b/backend/src/main/java/online/wesal/wesal/controller/AuthController.java index ffe7b13..062c923 100644 --- a/backend/src/main/java/online/wesal/wesal/controller/AuthController.java +++ b/backend/src/main/java/online/wesal/wesal/controller/AuthController.java @@ -8,9 +8,14 @@ import online.wesal.wesal.dto.LoginRequest; import online.wesal.wesal.dto.LoginResponse; import online.wesal.wesal.dto.UpdateUserRequest; import online.wesal.wesal.dto.UsernameSelectionRequest; +import online.wesal.wesal.dto.CheckInvitationRequest; +import online.wesal.wesal.dto.RegisterRequest; +import online.wesal.wesal.dto.RegisterResponse; import online.wesal.wesal.entity.User; import online.wesal.wesal.service.AuthService; import online.wesal.wesal.service.UserService; +import online.wesal.wesal.service.InvitationCodeService; +import online.wesal.wesal.config.JwtUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -29,6 +34,12 @@ public class AuthController { @Autowired private UserService userService; + @Autowired + private InvitationCodeService invitationCodeService; + + @Autowired + private JwtUtil jwtUtil; + @GetMapping("/") @Operation(summary = "Health check", description = "Returns API status") public ResponseEntity> healthCheck() { @@ -107,6 +118,70 @@ public class AuthController { } } + @PostMapping("/checkInvitation") + @Operation(summary = "Check invitation code", description = "Validates if an invitation code is valid and not expired") + public ResponseEntity> checkInvitation(@Valid @RequestBody CheckInvitationRequest request) { + try { + boolean isValid = invitationCodeService.isValidCode(request.getCode()); + if (isValid) { + return ResponseEntity.ok(Map.of( + "status", true, + "message", "Invitation code is valid", + "data", Map.of("code", request.getCode(), "valid", true) + )); + } else { + return ResponseEntity.ok(Map.of( + "status", false, + "message", "Invalid or expired invitation code", + "data", Map.of("code", request.getCode(), "valid", false) + )); + } + } catch (Exception e) { + return ResponseEntity.ok(Map.of( + "status", false, + "message", "Error validating invitation code" + )); + } + } + + @PostMapping("/register") + @Operation(summary = "Register new user", description = "Register a new user with invitation code, phone number, and password") + public ResponseEntity> register(@Valid @RequestBody RegisterRequest request) { + try { + User user = invitationCodeService.registerUserWithCode( + request.getCode(), + request.getPhoneNumber(), + request.getPassword() + ); + + // Auto-login: generate JWT token + String token = jwtUtil.generateToken(user.getPhoneNumber()); + + RegisterResponse registerResponse = new RegisterResponse(user, token); + + return ResponseEntity.ok(Map.of( + "status", true, + "message", "User registered successfully", + "data", registerResponse + )); + + } catch (RuntimeException e) { + String message; + if (e.getMessage().contains("already exists")) { + message = "A user with this phone number already exists"; + } else if (e.getMessage().contains("Invalid or expired")) { + message = "Invalid or expired invitation code"; + } else { + message = "Registration failed. Please try again"; + } + + return ResponseEntity.ok(Map.of( + "status", false, + "message", message + )); + } + } + @PostMapping("/admin/createUser") @Operation(summary = "Create new user (Admin only)", description = "Creates a new user - requires admin privileges") public ResponseEntity createUser(@Valid @RequestBody CreateUserRequest request) { diff --git a/backend/src/main/java/online/wesal/wesal/dto/CheckInvitationRequest.java b/backend/src/main/java/online/wesal/wesal/dto/CheckInvitationRequest.java new file mode 100644 index 0000000..9fe59c1 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/CheckInvitationRequest.java @@ -0,0 +1,23 @@ +package online.wesal.wesal.dto; + +import jakarta.validation.constraints.NotBlank; + +public class CheckInvitationRequest { + + @NotBlank + private String code; + + public CheckInvitationRequest() {} + + public CheckInvitationRequest(String code) { + this.code = code; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/RegisterRequest.java b/backend/src/main/java/online/wesal/wesal/dto/RegisterRequest.java new file mode 100644 index 0000000..deb8f8a --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/RegisterRequest.java @@ -0,0 +1,51 @@ +package online.wesal.wesal.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public class RegisterRequest { + + @NotBlank + private String code; + + @NotBlank + @Pattern(regexp = "^\\+[1-9]\\d{1,14}$", message = "Phone number must be in international format starting with + and country code") + private String phoneNumber; + + @NotBlank + @Size(min = 6) + private String password; + + public RegisterRequest() {} + + public RegisterRequest(String code, String phoneNumber, String password) { + this.code = code; + this.phoneNumber = phoneNumber; + this.password = password; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getPhoneNumber() { + return phoneNumber; + } + + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/RegisterResponse.java b/backend/src/main/java/online/wesal/wesal/dto/RegisterResponse.java new file mode 100644 index 0000000..efecbb8 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/RegisterResponse.java @@ -0,0 +1,32 @@ +package online.wesal.wesal.dto; + +import online.wesal.wesal.entity.User; + +public class RegisterResponse { + + private User user; + private String token; + + public RegisterResponse() {} + + public RegisterResponse(User user, String token) { + this.user = user; + this.token = token; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/entity/InvitationCode.java b/backend/src/main/java/online/wesal/wesal/entity/InvitationCode.java new file mode 100644 index 0000000..0520d49 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/entity/InvitationCode.java @@ -0,0 +1,72 @@ +package online.wesal.wesal.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; + +@Entity +@Table(name = "invitation_codes") +public class InvitationCode { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false) + @NotBlank + @Size(max = 200) + private String code; + + @Column(nullable = false) + private LocalDateTime expirationDate; + + @Column(nullable = false) + private LocalDateTime createdDate; + + public InvitationCode() { + this.createdDate = LocalDateTime.now(); + this.expirationDate = LocalDateTime.now().plusDays(30); + } + + public InvitationCode(String code) { + this(); + this.code = code; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public LocalDateTime getExpirationDate() { + return expirationDate; + } + + public void setExpirationDate(LocalDateTime expirationDate) { + this.expirationDate = expirationDate; + } + + public LocalDateTime getCreatedDate() { + return createdDate; + } + + public void setCreatedDate(LocalDateTime createdDate) { + this.createdDate = createdDate; + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(this.expirationDate); + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/entity/User.java b/backend/src/main/java/online/wesal/wesal/entity/User.java index 0d5333a..c1059fc 100644 --- a/backend/src/main/java/online/wesal/wesal/entity/User.java +++ b/backend/src/main/java/online/wesal/wesal/entity/User.java @@ -46,7 +46,7 @@ public class User { private String role = "USER"; @Column(columnDefinition = "TEXT") - private String subscriptions = "[\"newinvites\",\"invitesfollowup\",\"appnews\"]"; + private String subscriptions = "[\"postcomments\",\"newinvites\",\"invitesfollowup\",\"appnews\"]"; public User() {} @@ -130,13 +130,13 @@ public class User { public List getSubscriptions() { if (subscriptions == null || subscriptions.isEmpty()) { - return Arrays.asList("newinvites", "invitesfollowup", "appnews"); + return Arrays.asList("postcomments", "newinvites", "invitesfollowup", "appnews"); } try { ObjectMapper mapper = new ObjectMapper(); return mapper.readValue(subscriptions, new TypeReference>() {}); } catch (Exception e) { - return Arrays.asList("newinvites", "invitesfollowup", "appnews"); + return Arrays.asList("postcomments", "newinvites", "invitesfollowup", "appnews"); } } @@ -145,7 +145,7 @@ public class User { ObjectMapper mapper = new ObjectMapper(); this.subscriptions = mapper.writeValueAsString(subscriptions); } catch (Exception e) { - this.subscriptions = "[\"newinvites\",\"invitesfollowup\",\"appnews\"]"; + this.subscriptions = "[\"postcomments\",\"newinvites\",\"invitesfollowup\",\"appnews\"]"; } } } \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/repository/InvitationCodeRepository.java b/backend/src/main/java/online/wesal/wesal/repository/InvitationCodeRepository.java new file mode 100644 index 0000000..9f1d00e --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/repository/InvitationCodeRepository.java @@ -0,0 +1,19 @@ +package online.wesal.wesal.repository; + +import online.wesal.wesal.entity.InvitationCode; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Repository +public interface InvitationCodeRepository extends JpaRepository { + Optional findByCode(String code); + boolean existsByCode(String code); + + @Query("SELECT ic FROM InvitationCode ic WHERE ic.code = :code AND ic.expirationDate > :currentTime") + Optional findValidCodeByCode(@Param("code") String code, @Param("currentTime") LocalDateTime currentTime); +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/service/InvitationCodeService.java b/backend/src/main/java/online/wesal/wesal/service/InvitationCodeService.java new file mode 100644 index 0000000..2d6fdf9 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/service/InvitationCodeService.java @@ -0,0 +1,69 @@ +package online.wesal.wesal.service; + +import online.wesal.wesal.entity.InvitationCode; +import online.wesal.wesal.entity.User; +import online.wesal.wesal.repository.InvitationCodeRepository; +import online.wesal.wesal.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Optional; + +@Service +public class InvitationCodeService { + + @Autowired + private InvitationCodeRepository invitationCodeRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + public boolean isValidCode(String code) { + Optional invitationCodeOpt = invitationCodeRepository.findValidCodeByCode(code, LocalDateTime.now()); + return invitationCodeOpt.isPresent(); + } + + public Optional findValidCode(String code) { + return invitationCodeRepository.findValidCodeByCode(code, LocalDateTime.now()); + } + + @Transactional + public User registerUserWithCode(String code, String phoneNumber, String password) { + // Check if phone number already exists + if (userRepository.existsByPhoneNumber(phoneNumber)) { + throw new RuntimeException("User with this phone number already exists"); + } + + // Validate invitation code + Optional invitationCodeOpt = findValidCode(code); + if (invitationCodeOpt.isEmpty()) { + throw new RuntimeException("Invalid or expired invitation code"); + } + + InvitationCode invitationCode = invitationCodeOpt.get(); + + // Create new user + User user = new User(); + user.setPhoneNumber(phoneNumber); + user.setPassword(passwordEncoder.encode(password)); + user.setDisplayName("Guest"); + user.setRole("USER"); + user.setActivated(false); + user.setSubscriptions(Arrays.asList("postcomments", "newinvites", "invitesfollowup", "appnews")); + + // Save user + User savedUser = userRepository.save(user); + + // Remove (delete) the invitation code so it can't be used again + invitationCodeRepository.delete(invitationCode); + + return savedUser; + } +} \ No newline at end of file