feat(invite): add GET /v1/invites/{code} + rework POST accept as signup+join
- V027 migration: add invited_by FK column on household_invite
- HouseholdInvite entity: add invitedBy field, set on createInvite
- New DTOs: InviteInfoResponse, AcceptInviteRequest
- HouseholdService: add getInviteInfo(), rewrite acceptInvite(code, name, email, password) — creates UserAccount + joins household in one transaction
- HouseholdController: GET /v1/invites/{code} (unauthenticated), POST /v1/invites/{code}/accept creates session after join
- SecurityConfig: permitAll() for /v1/invites/*, sessionFixation().changeSessionId()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,11 +24,13 @@ public class SecurityConfig {
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll()
|
||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/v1/invites/*").permitAll()
|
||||
.requestMatchers("/v1/admin/**").hasAuthority("ROLE_ADMIN")
|
||||
.anyRequest().authenticated())
|
||||
.exceptionHandling(ex -> ex
|
||||
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
|
||||
.sessionManagement(session -> session
|
||||
.sessionFixation().changeSessionId()
|
||||
.maximumSessions(1));
|
||||
|
||||
return http.build();
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
package com.recipeapp.household;
|
||||
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import com.recipeapp.common.ApiResponse;
|
||||
import com.recipeapp.household.dto.*;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.security.Principal;
|
||||
@@ -71,11 +78,34 @@ public class HouseholdController {
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
|
||||
@PostMapping("/invites/{code}/accept")
|
||||
public ResponseEntity<ApiResponse<AcceptInviteResponse>> acceptInvite(
|
||||
Principal principal,
|
||||
@PathVariable String code) {
|
||||
AcceptInviteResponse response = householdService.acceptInvite(principal.getName(), code);
|
||||
@GetMapping("/invites/{code}")
|
||||
public ResponseEntity<ApiResponse<InviteInfoResponse>> getInviteInfo(@PathVariable String code) {
|
||||
InviteInfoResponse response = householdService.getInviteInfo(code);
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
|
||||
@PostMapping("/invites/{code}/accept")
|
||||
public ResponseEntity<ApiResponse<AcceptInviteResponse>> acceptInvite(
|
||||
@PathVariable String code,
|
||||
@Valid @RequestBody AcceptInviteRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
AcceptInviteResponse response = householdService.acceptInvite(
|
||||
code, request.name(), request.email(), request.password());
|
||||
authenticateInSession(request.email(), httpRequest);
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
|
||||
private void authenticateInSession(String email, HttpServletRequest request) {
|
||||
var oldSession = request.getSession(false);
|
||||
if (oldSession != null) {
|
||||
oldSession.invalidate();
|
||||
}
|
||||
var auth = UsernamePasswordAuthenticationToken.authenticated(
|
||||
email, null, List.of(new SimpleGrantedAuthority("ROLE_USER")));
|
||||
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
||||
context.setAuthentication(auth);
|
||||
SecurityContextHolder.setContext(context);
|
||||
request.getSession(true).setAttribute(
|
||||
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.recipeapp.common.ConflictException;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.common.ValidationException;
|
||||
import com.recipeapp.household.dto.*;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import com.recipeapp.household.entity.HouseholdInvite;
|
||||
import com.recipeapp.household.entity.HouseholdMember;
|
||||
@@ -38,6 +39,7 @@ public class HouseholdService {
|
||||
private final IngredientCategoryRepository ingredientCategoryRepository;
|
||||
private final TagRepository tagRepository;
|
||||
private final VarietyScoreConfigRepository varietyScoreConfigRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@Value("${app.base-url}")
|
||||
private String baseUrl;
|
||||
@@ -52,7 +54,8 @@ public class HouseholdService {
|
||||
IngredientRepository ingredientRepository,
|
||||
IngredientCategoryRepository ingredientCategoryRepository,
|
||||
TagRepository tagRepository,
|
||||
VarietyScoreConfigRepository varietyScoreConfigRepository) {
|
||||
VarietyScoreConfigRepository varietyScoreConfigRepository,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.householdRepository = householdRepository;
|
||||
this.householdMemberRepository = householdMemberRepository;
|
||||
@@ -61,6 +64,7 @@ public class HouseholdService {
|
||||
this.ingredientCategoryRepository = ingredientCategoryRepository;
|
||||
this.tagRepository = tagRepository;
|
||||
this.varietyScoreConfigRepository = varietyScoreConfigRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -167,30 +171,45 @@ public class HouseholdService {
|
||||
String code = generateInviteCode();
|
||||
Instant expiresAt = Instant.now().plusSeconds(48 * 3600);
|
||||
|
||||
HouseholdInvite invite = householdInviteRepository.save(
|
||||
new HouseholdInvite(household, code, expiresAt));
|
||||
HouseholdInvite invite = new HouseholdInvite(household, code, expiresAt);
|
||||
invite.setInvitedBy(member.getUser());
|
||||
householdInviteRepository.save(invite);
|
||||
|
||||
return toInviteResponse(invite);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AcceptInviteResponse acceptInvite(String userEmail, String code) {
|
||||
UserAccount user = findUser(userEmail);
|
||||
@Transactional(readOnly = true)
|
||||
public InviteInfoResponse getInviteInfo(String code) {
|
||||
HouseholdInvite invite = householdInviteRepository.findByInviteCode(code)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Invite not found or invalid"));
|
||||
|
||||
if (householdMemberRepository.findByUserEmailIgnoreCase(userEmail).isPresent()) {
|
||||
throw new ConflictException("User is already in a household");
|
||||
if ("used".equals(invite.getStatus()) || invite.getExpiresAt().isBefore(Instant.now())) {
|
||||
throw new ResourceNotFoundException("Invite not found or invalid");
|
||||
}
|
||||
|
||||
String inviterName = invite.getInvitedBy() != null
|
||||
? invite.getInvitedBy().getDisplayName()
|
||||
: invite.getHousehold().getCreatedBy().getDisplayName();
|
||||
|
||||
return new InviteInfoResponse(invite.getHousehold().getName(), inviterName);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AcceptInviteResponse acceptInvite(String code, String name, String email, String rawPassword) {
|
||||
if (userAccountRepository.existsByEmailIgnoreCase(email)) {
|
||||
throw new ConflictException("Email already registered");
|
||||
}
|
||||
|
||||
HouseholdInvite invite = householdInviteRepository.findByInviteCode(code)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Invite not found"));
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Invite not found or invalid"));
|
||||
|
||||
if ("used".equals(invite.getStatus())) {
|
||||
throw new ConflictException("Invite code already used");
|
||||
}
|
||||
if (invite.getExpiresAt().isBefore(Instant.now())) {
|
||||
throw new ValidationException("Invite code has expired");
|
||||
if ("used".equals(invite.getStatus()) || invite.getExpiresAt().isBefore(Instant.now())) {
|
||||
throw new ResourceNotFoundException("Invite not found or invalid");
|
||||
}
|
||||
|
||||
UserAccount user = userAccountRepository.save(
|
||||
new UserAccount(email, name, passwordEncoder.encode(rawPassword)));
|
||||
|
||||
invite.setStatus("used");
|
||||
householdInviteRepository.save(invite);
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.recipeapp.household.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record AcceptInviteRequest(
|
||||
@NotBlank String name,
|
||||
@NotBlank @Email String email,
|
||||
@NotBlank @Size(min = 8) String password
|
||||
) {}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.recipeapp.household.dto;
|
||||
|
||||
public record InviteInfoResponse(
|
||||
String householdName,
|
||||
String inviterName
|
||||
) {}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.recipeapp.household.entity;
|
||||
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
@@ -16,6 +17,10 @@ public class HouseholdInvite {
|
||||
@JoinColumn(name = "household_id", nullable = false)
|
||||
private Household household;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "invited_by")
|
||||
private UserAccount invitedBy;
|
||||
|
||||
@Column(name = "invite_code", nullable = false, unique = true, length = 20)
|
||||
private String inviteCode;
|
||||
|
||||
@@ -38,6 +43,8 @@ public class HouseholdInvite {
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public Household getHousehold() { return household; }
|
||||
public UserAccount getInvitedBy() { return invitedBy; }
|
||||
public void setInvitedBy(UserAccount invitedBy) { this.invitedBy = invitedBy; }
|
||||
public String getInviteCode() { return inviteCode; }
|
||||
public String getStatus() { return status; }
|
||||
public void setStatus(String status) { this.status = status; }
|
||||
|
||||
Reference in New Issue
Block a user