Add household and membership domain with role-based APIs
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:
Urban Modig
2025-10-06 21:46:30 +02:00
parent 89315e01dd
commit 84d7647481
17 changed files with 604 additions and 9 deletions

View 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();
}
}

View 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; }
}

View 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; }
}

View 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) {}
}

View File

@ -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);
}

View File

@ -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> {}

View 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");
}
}

View 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());
}
}

View File

@ -0,0 +1,26 @@
-- src/main/resources/db/migration/V2__households.sql
-- Households & Members (H2/PG compatible)
CREATE TABLE households (
id UUID PRIMARY KEY,
name VARCHAR(120) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE household_members (
id UUID PRIMARY KEY,
household_id UUID NOT NULL REFERENCES households(id) ON DELETE CASCADE,
user_sub VARCHAR(64) NOT NULL,
email VARCHAR(255),
display_name VARCHAR(120),
role VARCHAR(16) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (household_id, user_sub)
);
-- Optional: enforce role values (H2 accepts the CHECK too)
ALTER TABLE household_members
ADD CONSTRAINT chk_household_role CHECK (role IN ('OWNER','MEMBER'));
CREATE INDEX idx_household_members_user_sub ON household_members(user_sub);
CREATE INDEX idx_household_members_household ON household_members(household_id);

View File

@ -0,0 +1,55 @@
### 1. Get access token for user "maria"
POST http://localhost:8081/realms/hemhub/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
client_id=hemhub-public&
grant_type=password&
username=maria&
password=Passw0rd
> {%
client.global.set("token", response.body.access_token);
client.global.set("refresh_token", response.body.refresh_token);
%}
### 2. Decode token (optional, debugging)
GET https://jwt.io/?access_token={{token}}
### 3. Get current user info
GET http://localhost:8080/me
Authorization: Bearer {{token}}
### 4. Create a household
POST http://localhost:8080/api/v1/households
Content-Type: application/json
Authorization: Bearer {{token}}
{
"name": "Familjen Andersson"
}
> {% client.global.set("householdId", JSON.parse(response.body).id); %}
### 5. List my households
GET http://localhost:8080/api/v1/households
Authorization: Bearer {{token}}
### 6. List household members
GET http://localhost:8080/api/v1/households/{{householdId}}/members
Authorization: Bearer {{token}}
### 7. Add a member (requires OWNER role)
POST http://localhost:8080/api/v1/households/{{householdId}}/members
Content-Type: application/json
Authorization: Bearer {{token}}
{
"userSub": "ulf-sub",
"email": "ulf@example.com",
"displayName": "Ulf",
"role": "MEMBER"
}
### 8. List members again (should now include Ulf)
GET http://localhost:8080/api/v1/households/{{householdId}}/members
Authorization: Bearer {{token}}

View File

@ -0,0 +1,63 @@
package se.urmo.hemhub.integration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.UUID;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class HouseholdControllerIT {
@Autowired MockMvc mvc;
@Test
void create_household_and_list_for_user() throws Exception {
var jwtUser = jwt().jwt(j -> {
j.subject("sub-user-1");
j.claim("email","u1@example.com");
j.claim("preferred_username","u1");
j.claim("realm_access", java.util.Map.of("roles", java.util.List.of("OWNER","MEMBER")));
});
// create
mvc.perform(post("/api/v1/households")
.with(jwtUser)
.contentType("application/json")
.content("{\"name\":\"Familjen U1\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").exists());
// list
mvc.perform(get("/api/v1/households").with(jwtUser))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].name").value("Familjen U1"));
}
@Test
void add_member_requires_owner() throws Exception {
var owner = jwt().jwt(j -> { j.subject("owner-sub"); j.claim("email","o@ex.com"); j.claim("preferred_username","owner"); });
var other = jwt().jwt(j -> { j.subject("other-sub"); j.claim("email","x@ex.com"); j.claim("preferred_username","x"); });
// create household as owner
var res = mvc.perform(post("/api/v1/households").with(owner)
.contentType("application/json").content("{\"name\":\"H1\"}"))
.andExpect(status().isOk()).andReturn();
var id = com.jayway.jsonpath.JsonPath.read(res.getResponse().getContentAsString(), "$.id");
// cannot add as non-owner
mvc.perform(post("/api/v1/households/"+id+"/members").with(other)
.contentType("application/json")
.content("{\"userSub\":\"u2\",\"role\":\"MEMBER\"}"))
.andExpect(status().isForbidden());
}
}

View File

@ -1,4 +1,4 @@
package se.urmo.hemhub.web;
package se.urmo.hemhub.integration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;

View File

@ -1,4 +1,4 @@
package se.urmo.hemhub.web;
package se.urmo.hemhub.integration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;

View File

@ -6,6 +6,12 @@ spring:
password:
jpa:
hibernate:
ddl-auto: none
ddl-auto: none # 👈 turn off Hibernate's validate/ddl in tests
properties:
hibernate.hbm2ddl.auto: none
flyway:
enabled: false
enabled: true
locations: classpath:db/migration
logging:
level:
org.flywaydb.core: INFO