Add household and membership domain with role-based APIs
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Introduced `Household` and `HouseholdMember` entities, services, and repositories, enabling role-based access (`OWNER` and `MEMBER`). Added REST APIs for household creation, member management, and listing. Configured Flyway migrations, updated tests, and included IntelliJ HTTP client script for API testing. Updated `README.md` with feature details, usage instructions, and architecture overview.
This commit is contained in:
32
src/main/java/se/urmo/hemhub/config/HouseholdGuard.java
Normal file
32
src/main/java/se/urmo/hemhub/config/HouseholdGuard.java
Normal file
@ -0,0 +1,32 @@
|
||||
package se.urmo.hemhub.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.stereotype.Component;
|
||||
import se.urmo.hemhub.domain.HouseholdMember;
|
||||
import se.urmo.hemhub.repo.HouseholdMemberRepository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class HouseholdGuard {
|
||||
private final HouseholdMemberRepository members;
|
||||
|
||||
public boolean isMember(UUID householdId, Authentication auth) {
|
||||
var sub = jwtSub(auth);
|
||||
return members.existsByHouseholdIdAndUserSub(householdId, sub);
|
||||
}
|
||||
|
||||
public boolean isOwner(UUID householdId, Authentication auth) {
|
||||
var sub = jwtSub(auth);
|
||||
return members.findByHouseholdIdAndUserSub(householdId, sub)
|
||||
.map(m -> m.getRole() == HouseholdMember.Role.OWNER).orElse(false);
|
||||
}
|
||||
|
||||
private static String jwtSub(Authentication auth) {
|
||||
if (auth == null || !(auth.getPrincipal() instanceof Jwt jwt)) throw new IllegalStateException("No JWT");
|
||||
return jwt.getSubject();
|
||||
}
|
||||
}
|
||||
20
src/main/java/se/urmo/hemhub/domain/Household.java
Normal file
20
src/main/java/se/urmo/hemhub/domain/Household.java
Normal file
@ -0,0 +1,20 @@
|
||||
package se.urmo.hemhub.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "households")
|
||||
public class Household {
|
||||
@Id private UUID id;
|
||||
@Column(nullable=false, length=120) private String name;
|
||||
@Column(name="created_at", nullable=false) private Instant createdAt = Instant.now();
|
||||
|
||||
protected Household() {}
|
||||
public Household(UUID id, String name) { this.id=id; this.name=name; }
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public String getName() { return name; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
}
|
||||
39
src/main/java/se/urmo/hemhub/domain/HouseholdMember.java
Normal file
39
src/main/java/se/urmo/hemhub/domain/HouseholdMember.java
Normal file
@ -0,0 +1,39 @@
|
||||
package se.urmo.hemhub.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "household_members",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames={"household_id","user_sub"}))
|
||||
public class HouseholdMember {
|
||||
public enum Role { OWNER, MEMBER }
|
||||
|
||||
@Id private UUID id;
|
||||
|
||||
@ManyToOne(optional=false, fetch=FetchType.LAZY)
|
||||
@JoinColumn(name="household_id")
|
||||
private Household household;
|
||||
|
||||
@Column(name="user_sub", nullable=false, length=64) private String userSub;
|
||||
@Column(nullable=true, length=255) private String email;
|
||||
@Column(name="display_name", length=120) private String displayName;
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 16)
|
||||
private Role role;
|
||||
@Column(name="created_at", nullable=false) private Instant createdAt = Instant.now();
|
||||
|
||||
protected HouseholdMember() {}
|
||||
public HouseholdMember(UUID id, Household h, String userSub, String email, String displayName, Role role) {
|
||||
this.id=id; this.household=h; this.userSub=userSub; this.email=email; this.displayName=displayName; this.role=role;
|
||||
}
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public Household getHousehold() { return household; }
|
||||
public String getUserSub() { return userSub; }
|
||||
public Role getRole() { return role; }
|
||||
}
|
||||
12
src/main/java/se/urmo/hemhub/dto/HouseholdDtos.java
Normal file
12
src/main/java/se/urmo/hemhub/dto/HouseholdDtos.java
Normal file
@ -0,0 +1,12 @@
|
||||
package se.urmo.hemhub.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.*;
|
||||
|
||||
public class HouseholdDtos {
|
||||
public record CreateHouseholdRequest(@NotBlank String name) {}
|
||||
public record HouseholdResponse(UUID id, String name) {}
|
||||
public record MemberResponse(UUID id, String userSub, String email, String displayName, String role) {}
|
||||
public record AddMemberRequest(@NotNull String userSub, String email, String displayName, @NotNull String role) {}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package se.urmo.hemhub.repo;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import se.urmo.hemhub.domain.HouseholdMember;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
public interface HouseholdMemberRepository extends JpaRepository<HouseholdMember, UUID> {
|
||||
List<HouseholdMember> findByUserSub(String userSub);
|
||||
|
||||
// ✅ Use property path "householdId"
|
||||
boolean existsByHouseholdIdAndUserSub(UUID householdId, String userSub);
|
||||
Optional<HouseholdMember> findByHouseholdIdAndUserSub(UUID householdId, String userSub);
|
||||
List<HouseholdMember> findByHouseholdId(UUID householdId);
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package se.urmo.hemhub.repo;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import se.urmo.hemhub.domain.Household;
|
||||
import java.util.*;
|
||||
|
||||
public interface HouseholdRepository extends JpaRepository<Household, UUID> {}
|
||||
58
src/main/java/se/urmo/hemhub/service/HouseholdService.java
Normal file
58
src/main/java/se/urmo/hemhub/service/HouseholdService.java
Normal file
@ -0,0 +1,58 @@
|
||||
package se.urmo.hemhub.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import se.urmo.hemhub.domain.Household;
|
||||
import se.urmo.hemhub.domain.HouseholdMember;
|
||||
import se.urmo.hemhub.repo.HouseholdMemberRepository;
|
||||
import se.urmo.hemhub.repo.HouseholdRepository;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class HouseholdService {
|
||||
private final HouseholdRepository households;
|
||||
private final HouseholdMemberRepository members;
|
||||
|
||||
@Transactional
|
||||
public Household createHousehold(String name, String creatorSub, String email, String displayName) {
|
||||
var h = new Household(UUID.randomUUID(), name);
|
||||
households.save(h);
|
||||
var m = new HouseholdMember(UUID.randomUUID(), h, creatorSub, email, displayName, HouseholdMember.Role.OWNER);
|
||||
members.save(m);
|
||||
return h;
|
||||
}
|
||||
|
||||
@Transactional(readOnly=true)
|
||||
public List<Household> listForUser(String userSub) {
|
||||
return members.findByUserSub(userSub).stream().map(HouseholdMember::getHousehold).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public HouseholdMember addMember(UUID householdId, String requesterSub, String userSub, String email, String displayName, HouseholdMember.Role role) {
|
||||
var req = members.findByHouseholdIdAndUserSub(householdId, requesterSub)
|
||||
.orElseThrow(() -> new NoSuchElementException("Requester not member of household"));
|
||||
if (req.getRole() != HouseholdMember.Role.OWNER) throw new SecurityException("Only OWNER may add members");
|
||||
|
||||
var h = households.findById(householdId).orElseThrow();
|
||||
if (members.existsByHouseholdIdAndUserSub(householdId, userSub)) {
|
||||
throw new IllegalStateException("User already member");
|
||||
}
|
||||
var m = new HouseholdMember(UUID.randomUUID(), h, userSub, email, displayName, role);
|
||||
return members.save(m);
|
||||
}
|
||||
|
||||
|
||||
@Transactional(readOnly=true)
|
||||
public List<HouseholdMember> listMembers(UUID householdId, String requesterSub) {
|
||||
ensureMember(householdId, requesterSub);
|
||||
return members.findByHouseholdId(householdId);
|
||||
}
|
||||
|
||||
private void ensureMember(UUID householdId, String sub) {
|
||||
if (!members.existsByHouseholdIdAndUserSub(householdId, sub))
|
||||
throw new SecurityException("Forbidden for non-members");
|
||||
}
|
||||
}
|
||||
69
src/main/java/se/urmo/hemhub/web/HouseholdController.java
Normal file
69
src/main/java/se/urmo/hemhub/web/HouseholdController.java
Normal file
@ -0,0 +1,69 @@
|
||||
package se.urmo.hemhub.web;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import se.urmo.hemhub.domain.HouseholdMember;
|
||||
import se.urmo.hemhub.dto.HouseholdDtos.AddMemberRequest;
|
||||
import se.urmo.hemhub.dto.HouseholdDtos.CreateHouseholdRequest;
|
||||
import se.urmo.hemhub.dto.HouseholdDtos.HouseholdResponse;
|
||||
import se.urmo.hemhub.dto.HouseholdDtos.MemberResponse;
|
||||
import se.urmo.hemhub.service.HouseholdService;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static java.util.stream.Collectors.toList;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
@RequiredArgsConstructor
|
||||
public class HouseholdController {
|
||||
private final HouseholdService service;
|
||||
|
||||
@PostMapping("/households")
|
||||
public HouseholdResponse create(@Valid @RequestBody CreateHouseholdRequest req, Authentication auth) {
|
||||
var jwt = (Jwt) auth.getPrincipal();
|
||||
var h = service.createHousehold(req.name(), jwt.getSubject(), jwt.getClaimAsString("email"), jwt.getClaimAsString("preferred_username"));
|
||||
return new HouseholdResponse(h.getId(), h.getName());
|
||||
}
|
||||
|
||||
@GetMapping("/households")
|
||||
public List<HouseholdResponse> myHouseholds(Authentication auth) {
|
||||
var jwt = (Jwt) auth.getPrincipal();
|
||||
return service.listForUser(jwt.getSubject()).stream()
|
||||
.map(h -> new HouseholdResponse(h.getId(), h.getName()))
|
||||
.collect(toList());
|
||||
}
|
||||
|
||||
@PreAuthorize("@householdGuard.isOwner(#householdId, authentication)")
|
||||
@PostMapping("/households/{householdId}/members")
|
||||
public List<MemberResponse> addMember(@PathVariable UUID householdId, @Valid @RequestBody AddMemberRequest req, Authentication auth) {
|
||||
var role = HouseholdMember.Role.valueOf(req.role());
|
||||
var jwt = (Jwt) auth.getPrincipal();
|
||||
service.addMember(householdId, jwt.getSubject(), req.userSub(), req.email(), req.displayName(), role);
|
||||
return listMembersInternal(householdId, auth);
|
||||
}
|
||||
|
||||
@PreAuthorize("@householdGuard.isMember(#householdId, authentication)")
|
||||
@GetMapping("/households/{householdId}/members")
|
||||
public List<MemberResponse> members(@PathVariable UUID householdId, Authentication auth) {
|
||||
return listMembersInternal(householdId, auth);
|
||||
}
|
||||
|
||||
private List<MemberResponse> listMembersInternal(UUID householdId, Authentication auth) {
|
||||
var jwt = (Jwt) auth.getPrincipal();
|
||||
return service.listMembers(householdId, jwt.getSubject()).stream()
|
||||
.map(m -> new MemberResponse(m.getId(), m.getUserSub(), m.getUserSub().equals(jwt.getSubject()) ? jwt.getClaimAsString("email") : m.getEmail(),
|
||||
m.getUserSub().equals(jwt.getSubject()) ? jwt.getClaimAsString("preferred_username") : m.getUserSub(), m.getRole().name()))
|
||||
.collect(toList());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user