feat(members): implement /members page — Kachel-Ansicht (E2, issue #48) #58

Merged
marcel merged 10 commits from feat/issue-48-members-kachel into master 2026-04-10 20:34:24 +02:00
33 changed files with 2105 additions and 6 deletions

View File

@@ -9,6 +9,8 @@ import org.springframework.web.bind.annotation.*;
import java.security.Principal; import java.security.Principal;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.UUID;
@RestController @RestController
@RequestMapping("/v1") @RequestMapping("/v1")
@@ -40,12 +42,35 @@ public class HouseholdController {
return ResponseEntity.ok(members); return ResponseEntity.ok(members);
} }
@GetMapping("/households/mine/invites")
public ResponseEntity<ApiResponse<InviteResponse>> getActiveInvite(Principal principal) {
Optional<InviteResponse> invite = householdService.getActiveInvite(principal.getName());
return invite
.map(r -> ResponseEntity.ok(ApiResponse.success(r)))
.orElse(ResponseEntity.noContent().build());
}
@PostMapping("/households/mine/invites") @PostMapping("/households/mine/invites")
public ResponseEntity<ApiResponse<InviteResponse>> createInvite(Principal principal) { public ResponseEntity<ApiResponse<InviteResponse>> createInvite(Principal principal) {
InviteResponse response = householdService.createInvite(principal.getName()); InviteResponse response = householdService.createInvite(principal.getName());
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response)); return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response));
} }
@DeleteMapping("/households/mine/members/{userId}")
public ResponseEntity<Void> removeMember(Principal principal, @PathVariable UUID userId) {
householdService.removeMember(principal.getName(), userId);
return ResponseEntity.noContent().build();
}
@PatchMapping("/households/mine/members/{userId}")
public ResponseEntity<ApiResponse<MemberResponse>> changeMemberRole(
Principal principal,
@PathVariable UUID userId,
@Valid @RequestBody ChangeRoleRequest request) {
MemberResponse response = householdService.changeMemberRole(principal.getName(), userId, request.role());
return ResponseEntity.ok(ApiResponse.success(response));
}
@PostMapping("/invites/{code}/accept") @PostMapping("/invites/{code}/accept")
public ResponseEntity<ApiResponse<AcceptInviteResponse>> acceptInvite( public ResponseEntity<ApiResponse<AcceptInviteResponse>> acceptInvite(
Principal principal, Principal principal,

View File

@@ -8,4 +8,5 @@ import java.util.UUID;
public interface HouseholdInviteRepository extends JpaRepository<HouseholdInvite, UUID> { public interface HouseholdInviteRepository extends JpaRepository<HouseholdInvite, UUID> {
Optional<HouseholdInvite> findByInviteCode(String inviteCode); Optional<HouseholdInvite> findByInviteCode(String inviteCode);
Optional<HouseholdInvite> findByHouseholdIdAndInvalidatedAtIsNull(UUID householdId);
} }

View File

@@ -10,4 +10,6 @@ import java.util.UUID;
public interface HouseholdMemberRepository extends JpaRepository<HouseholdMember, UUID> { public interface HouseholdMemberRepository extends JpaRepository<HouseholdMember, UUID> {
Optional<HouseholdMember> findByUserEmailIgnoreCase(String email); Optional<HouseholdMember> findByUserEmailIgnoreCase(String email);
List<HouseholdMember> findByHouseholdId(UUID householdId); List<HouseholdMember> findByHouseholdId(UUID householdId);
Optional<HouseholdMember> findByHouseholdIdAndUserId(UUID householdId, UUID userId);
long countByHouseholdIdAndRole(UUID householdId, String role);
} }

View File

@@ -17,12 +17,15 @@ import com.recipeapp.recipe.TagRepository;
import com.recipeapp.recipe.entity.Ingredient; import com.recipeapp.recipe.entity.Ingredient;
import com.recipeapp.recipe.entity.IngredientCategory; import com.recipeapp.recipe.entity.IngredientCategory;
import com.recipeapp.recipe.entity.Tag; import com.recipeapp.recipe.entity.Tag;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service @Service
public class HouseholdService { public class HouseholdService {
@@ -36,6 +39,9 @@ public class HouseholdService {
private final TagRepository tagRepository; private final TagRepository tagRepository;
private final VarietyScoreConfigRepository varietyScoreConfigRepository; private final VarietyScoreConfigRepository varietyScoreConfigRepository;
@Value("${app.base-url}")
private String baseUrl;
private static final SecureRandom RANDOM = new SecureRandom(); private static final SecureRandom RANDOM = new SecureRandom();
private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
@@ -91,21 +97,80 @@ public class HouseholdService {
.toList(); .toList();
} }
@Transactional
public MemberResponse changeMemberRole(String requesterEmail, UUID targetUserId, String newRole) {
HouseholdMember requester = findMembership(requesterEmail);
UUID householdId = requester.getHousehold().getId();
HouseholdMember target = householdMemberRepository
.findByHouseholdIdAndUserId(householdId, targetUserId)
.orElseThrow(() -> new ResourceNotFoundException("Member not found in this household"));
if (target.getRole().equals(newRole)) {
return toMemberResponse(target);
}
if ("member".equals(newRole) && "planner".equals(target.getRole())) {
long plannerCount = householdMemberRepository.countByHouseholdIdAndRole(householdId, "planner");
if (plannerCount <= 1) {
throw new ConflictException("Cannot degrade the last planner");
}
}
target.setRole(newRole);
return toMemberResponse(householdMemberRepository.save(target));
}
@Transactional
public void removeMember(String requesterEmail, UUID targetUserId) {
HouseholdMember requester = findMembership(requesterEmail);
UUID householdId = requester.getHousehold().getId();
HouseholdMember target = householdMemberRepository
.findByHouseholdIdAndUserId(householdId, targetUserId)
.orElseThrow(() -> new ResourceNotFoundException("Member not found in this household"));
if (target.getUser().getEmail().equalsIgnoreCase(requesterEmail)) {
throw new ConflictException("Planner cannot remove yourself");
}
if ("planner".equals(target.getRole())) {
long plannerCount = householdMemberRepository.countByHouseholdIdAndRole(householdId, "planner");
if (plannerCount <= 1) {
throw new ConflictException("Cannot remove the last planner");
}
}
householdMemberRepository.delete(target);
}
@Transactional(readOnly = true)
public Optional<InviteResponse> getActiveInvite(String userEmail) {
HouseholdMember member = findMembership(userEmail);
return householdInviteRepository
.findByHouseholdIdAndInvalidatedAtIsNull(member.getHousehold().getId())
.filter(invite -> invite.getExpiresAt().isAfter(Instant.now()))
.map(this::toInviteResponse);
}
@Transactional @Transactional
public InviteResponse createInvite(String userEmail) { public InviteResponse createInvite(String userEmail) {
HouseholdMember member = findMembership(userEmail); HouseholdMember member = findMembership(userEmail);
Household household = member.getHousehold(); Household household = member.getHousehold();
householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(household.getId())
.ifPresent(existing -> {
existing.setInvalidatedAt(Instant.now());
householdInviteRepository.save(existing);
});
String code = generateInviteCode(); String code = generateInviteCode();
Instant expiresAt = Instant.now().plusSeconds(48 * 3600); Instant expiresAt = Instant.now().plusSeconds(48 * 3600);
HouseholdInvite invite = householdInviteRepository.save( HouseholdInvite invite = householdInviteRepository.save(
new HouseholdInvite(household, code, expiresAt)); new HouseholdInvite(household, code, expiresAt));
return new InviteResponse( return toInviteResponse(invite);
invite.getInviteCode(),
"https://yourapp.com/join/" + invite.getInviteCode(),
invite.getExpiresAt());
} }
@Transactional @Transactional
@@ -204,4 +269,11 @@ public class HouseholdService {
member.getRole(), member.getRole(),
member.getJoinedAt()); member.getJoinedAt());
} }
private InviteResponse toInviteResponse(HouseholdInvite invite) {
return new InviteResponse(
invite.getInviteCode(),
baseUrl + "/join/" + invite.getInviteCode(),
invite.getExpiresAt());
}
} }

View File

@@ -0,0 +1,10 @@
package com.recipeapp.household.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
public record ChangeRoleRequest(
@NotBlank
@Pattern(regexp = "planner|member", message = "role must be 'planner' or 'member'")
String role
) {}

View File

@@ -25,6 +25,9 @@ public class HouseholdInvite {
@Column(name = "expires_at", nullable = false) @Column(name = "expires_at", nullable = false)
private Instant expiresAt; private Instant expiresAt;
@Column(name = "invalidated_at")
private Instant invalidatedAt;
protected HouseholdInvite() {} protected HouseholdInvite() {}
public HouseholdInvite(Household household, String inviteCode, Instant expiresAt) { public HouseholdInvite(Household household, String inviteCode, Instant expiresAt) {
@@ -39,4 +42,6 @@ public class HouseholdInvite {
public String getStatus() { return status; } public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; } public void setStatus(String status) { this.status = status; }
public Instant getExpiresAt() { return expiresAt; } public Instant getExpiresAt() { return expiresAt; }
public Instant getInvalidatedAt() { return invalidatedAt; }
public void setInvalidatedAt(Instant invalidatedAt) { this.invalidatedAt = invalidatedAt; }
} }

View File

@@ -2,3 +2,6 @@ spring:
flyway: flyway:
locations: classpath:db/migration,classpath:db/seed locations: classpath:db/migration,classpath:db/seed
out-of-order: true out-of-order: true
app:
base-url: ${APP_BASE_URL:http://localhost:5173}

View File

@@ -30,3 +30,6 @@ spring:
server: server:
port: 8080 port: 8080
app:
base-url: http://localhost:5173

View File

@@ -0,0 +1,6 @@
ALTER TABLE household_invite
ADD COLUMN invalidated_at timestamptz;
CREATE UNIQUE INDEX uq_household_invite_active
ON household_invite (household_id)
WHERE invalidated_at IS NULL;

View File

@@ -15,10 +15,12 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@@ -103,6 +105,58 @@ class HouseholdControllerTest {
.andExpect(jsonPath("$.data.inviteCode").value("ABC12XYZ")); .andExpect(jsonPath("$.data.inviteCode").value("ABC12XYZ"));
} }
@Test
void getActiveInviteShouldReturn200WithInvite() throws Exception {
var response = new InviteResponse("ACTIVE12", "https://yourapp.com/join/ACTIVE12",
Instant.now().plusSeconds(172800));
when(householdService.getActiveInvite("sarah@example.com")).thenReturn(Optional.of(response));
mockMvc.perform(get("/v1/households/mine/invites")
.principal(() -> "sarah@example.com"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.data.inviteCode").value("ACTIVE12"));
}
@Test
void getActiveInviteShouldReturn204WhenNoActiveInvite() throws Exception {
when(householdService.getActiveInvite("sarah@example.com")).thenReturn(Optional.empty());
mockMvc.perform(get("/v1/households/mine/invites")
.principal(() -> "sarah@example.com"))
.andExpect(status().isNoContent());
}
@Test
void deleteMemberShouldReturn204() throws Exception {
var memberId = UUID.randomUUID();
mockMvc.perform(delete("/v1/households/mine/members/" + memberId)
.principal(() -> "sarah@example.com"))
.andExpect(status().isNoContent());
verify(householdService).removeMember("sarah@example.com", memberId);
}
@Test
void patchMemberRoleShouldReturn200() throws Exception {
var memberId = UUID.randomUUID();
var memberResponse = new MemberResponse(memberId, "Tom", "planner", Instant.now());
var request = new ChangeRoleRequest("planner");
when(householdService.changeMemberRole("sarah@example.com", memberId, "planner"))
.thenReturn(memberResponse);
mockMvc.perform(patch("/v1/households/mine/members/" + memberId)
.principal(() -> "sarah@example.com")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.data.role").value("planner"));
}
@Test @Test
void acceptInviteShouldReturn200() throws Exception { void acceptInviteShouldReturn200() throws Exception {
var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member"); var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member");

View File

@@ -17,11 +17,14 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
@@ -42,6 +45,11 @@ class HouseholdServiceTest {
@InjectMocks @InjectMocks
private HouseholdService householdService; private HouseholdService householdService;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(householdService, "baseUrl", "http://localhost:5173");
}
private UserAccount testUser() { private UserAccount testUser() {
return new UserAccount("sarah@example.com", "Sarah", "hashed"); return new UserAccount("sarah@example.com", "Sarah", "hashed");
} }
@@ -131,6 +139,21 @@ class HouseholdServiceTest {
verify(householdInviteRepository).save(any(HouseholdInvite.class)); verify(householdInviteRepository).save(any(HouseholdInvite.class));
} }
@Test
void createInviteShouldBuildShareUrlWithConfiguredBaseUrl() {
var user = testUser();
var household = new Household("Smith family", user);
var member = new HouseholdMember(household, user, "planner");
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
when(householdInviteRepository.save(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0));
InviteResponse result = householdService.createInvite("sarah@example.com");
assertThat(result.shareUrl()).startsWith("http://localhost:5173/join/");
assertThat(result.shareUrl()).endsWith(result.inviteCode());
}
@Test @Test
void acceptInviteShouldAddUserAsMember() { void acceptInviteShouldAddUserAsMember() {
var user = new UserAccount("tom@example.com", "Tom", "hashed"); var user = new UserAccount("tom@example.com", "Tom", "hashed");
@@ -223,6 +246,187 @@ class HouseholdServiceTest {
.isInstanceOf(ResourceNotFoundException.class); .isInstanceOf(ResourceNotFoundException.class);
} }
// ── changeMemberRole ──────────────────────────────────────────────────────
@Test
void changeMemberRoleShouldUpdateRole() {
var planner = testUser();
var target = new UserAccount("tom@example.com", "Tom", "hashed");
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var targetMembership = new HouseholdMember(household, target, "member");
var targetId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0));
MemberResponse result = householdService.changeMemberRole("sarah@example.com", targetId, "planner");
assertThat(result.role()).isEqualTo("planner");
}
@Test
void changeMemberRoleShouldBeIdempotentWhenRoleUnchanged() {
var planner = testUser();
var target = new UserAccount("tom@example.com", "Tom", "hashed");
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var targetMembership = new HouseholdMember(household, target, "member");
var targetId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
MemberResponse result = householdService.changeMemberRole("sarah@example.com", targetId, "member");
assertThat(result.role()).isEqualTo("member");
verify(householdMemberRepository, never()).save(any());
}
@Test
void changeMemberRoleShouldThrow409WhenDegradingLastPlanner() {
var planner = testUser();
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var targetId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.countByHouseholdIdAndRole(any(), eq("planner"))).thenReturn(1L);
assertThatThrownBy(() -> householdService.changeMemberRole("sarah@example.com", targetId, "member"))
.isInstanceOf(ConflictException.class)
.hasMessageContaining("last planner");
}
@Test
void changeMemberRoleShouldThrow404WhenTargetNotInHousehold() {
var planner = testUser();
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var unknownId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(unknownId))).thenReturn(Optional.empty());
assertThatThrownBy(() -> householdService.changeMemberRole("sarah@example.com", unknownId, "planner"))
.isInstanceOf(ResourceNotFoundException.class);
}
// ── removeMember ──────────────────────────────────────────────────────────
@Test
void removeMemberShouldDeleteMember() {
var planner = testUser();
var target = new UserAccount("tom@example.com", "Tom", "hashed");
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var targetMembership = new HouseholdMember(household, target, "member");
var targetId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
householdService.removeMember("sarah@example.com", targetId);
verify(householdMemberRepository).delete(targetMembership);
}
@Test
void removeMemberShouldThrow409WhenPlannerTriesToRemoveSelf() {
var planner = testUser();
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var plannerId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(plannerId))).thenReturn(Optional.of(plannerMembership));
assertThatThrownBy(() -> householdService.removeMember("sarah@example.com", plannerId))
.isInstanceOf(ConflictException.class)
.hasMessageContaining("cannot remove yourself");
}
@Test
void removeMemberShouldThrow409WhenRemovingLastPlanner() {
var planner = testUser();
var target = new UserAccount("tom@example.com", "Tom", "hashed");
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var targetMembership = new HouseholdMember(household, target, "planner");
var targetId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership));
when(householdMemberRepository.countByHouseholdIdAndRole(any(), eq("planner"))).thenReturn(1L);
assertThatThrownBy(() -> householdService.removeMember("sarah@example.com", targetId))
.isInstanceOf(ConflictException.class)
.hasMessageContaining("last planner");
}
@Test
void removeMemberShouldThrow404WhenTargetNotInHousehold() {
var planner = testUser();
var household = new Household("Smith family", planner);
var plannerMembership = new HouseholdMember(household, planner, "planner");
var unknownId = UUID.randomUUID();
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership));
when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(unknownId))).thenReturn(Optional.empty());
assertThatThrownBy(() -> householdService.removeMember("sarah@example.com", unknownId))
.isInstanceOf(ResourceNotFoundException.class);
}
// ── getActiveInvite ───────────────────────────────────────────────────────
@Test
void getActiveInviteShouldReturnActiveInviteResponse() {
var user = testUser();
var household = new Household("Smith family", user);
var member = new HouseholdMember(household, user, "planner");
var invite = new HouseholdInvite(household, "ACTIVE123", Instant.now().plusSeconds(86400));
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(invite));
Optional<InviteResponse> result = householdService.getActiveInvite("sarah@example.com");
assertThat(result).isPresent();
assertThat(result.get().inviteCode()).isEqualTo("ACTIVE123");
}
@Test
void getActiveInviteShouldReturnEmptyWhenNoActiveInvite() {
var user = testUser();
var household = new Household("Smith family", user);
var member = new HouseholdMember(household, user, "planner");
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.empty());
Optional<InviteResponse> result = householdService.getActiveInvite("sarah@example.com");
assertThat(result).isEmpty();
}
@Test
void getActiveInviteShouldReturnEmptyWhenExpired() {
var user = testUser();
var household = new Household("Smith family", user);
var member = new HouseholdMember(household, user, "planner");
var invite = new HouseholdInvite(household, "EXPIRED1", Instant.now().minusSeconds(3600));
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(invite));
Optional<InviteResponse> result = householdService.getActiveInvite("sarah@example.com");
assertThat(result).isEmpty();
}
@Test @Test
void getMembersShouldReturnAllMembers() { void getMembersShouldReturnAllMembers() {
var user1 = testUser(); var user1 = testUser();
@@ -256,4 +460,21 @@ class HouseholdServiceTest {
assertThatThrownBy(() -> householdService.createInvite("orphan@example.com")) assertThatThrownBy(() -> householdService.createInvite("orphan@example.com"))
.isInstanceOf(ResourceNotFoundException.class); .isInstanceOf(ResourceNotFoundException.class);
} }
@Test
void createInviteShouldInvalidatePreviousActiveInvite() {
var user = testUser();
var household = new Household("Smith family", user);
var member = new HouseholdMember(household, user, "planner");
var existingInvite = new HouseholdInvite(household, "OLD12345", Instant.now().plusSeconds(86400));
when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member));
when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(existingInvite));
when(householdInviteRepository.save(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0));
householdService.createInvite("sarah@example.com");
assertThat(existingInvite.getInvalidatedAt()).isNotNull();
verify(householdInviteRepository, times(2)).save(any(HouseholdInvite.class));
}
} }

View File

@@ -203,7 +203,7 @@ export interface paths {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
get?: never; get: operations["getActiveInvite"];
put?: never; put?: never;
post: operations["createInvite"]; post: operations["createInvite"];
delete?: never; delete?: never;
@@ -212,6 +212,24 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/v1/households/mine/members/{userId}": {
parameters: {
query?: never;
header?: never;
path: {
userId: string;
};
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["removeMember"];
options?: never;
head?: never;
patch: operations["changeMemberRole"];
trace?: never;
};
"/v1/cooking-logs": { "/v1/cooking-logs": {
parameters: { parameters: {
query?: never; query?: never;
@@ -763,6 +781,14 @@ export interface components {
/** Format: date-time */ /** Format: date-time */
joinedAt?: string; joinedAt?: string;
}; };
ChangeRoleRequest: {
role: string;
};
ApiResponseMemberResponse: {
status?: string;
data?: components["schemas"]["MemberResponse"];
meta?: components["schemas"]["Meta"];
};
ApiResponseInviteResponse: { ApiResponseInviteResponse: {
status?: string; status?: string;
data?: components["schemas"]["InviteResponse"]; data?: components["schemas"]["InviteResponse"];
@@ -2010,6 +2036,97 @@ export interface operations {
}; };
}; };
}; };
getActiveInvite: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseInviteResponse"];
};
};
/** @description No active invite */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
removeMember: {
parameters: {
query?: never;
header?: never;
path: {
userId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiError"];
};
};
};
};
changeMemberRole: {
parameters: {
query?: never;
header?: never;
path: {
userId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ChangeRoleRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseMemberResponse"];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiError"];
};
};
};
};
listAuditLog: { listAuditLog: {
parameters: { parameters: {
query?: { query?: {

View File

@@ -0,0 +1,50 @@
<script lang="ts">
let {
options,
value,
onchange
}: {
options: { value: string; label: string }[];
value: string;
onchange: (value: string) => void;
} = $props();
</script>
<div role="group" class="segmented-control">
{#each options as option (option.value)}
<button
type="button"
aria-pressed={option.value === value ? 'true' : 'false'}
class="segment"
class:active={option.value === value}
onclick={() => onchange(option.value)}
>
{option.label}
</button>
{/each}
</div>
<style>
.segmented-control {
display: flex;
background: var(--color-surface-raised);
border-radius: var(--radius-md);
padding: 2px;
}
.segment {
border: none;
cursor: pointer;
padding: 4px 12px;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.04em;
background: transparent;
border-radius: var(--radius-sm);
color: inherit;
}
.segment.active {
background: var(--color-page);
}
</style>

View File

@@ -0,0 +1,30 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import SegmentedControl from './SegmentedControl.svelte';
const options = [
{ value: 'planner', label: 'Planer' },
{ value: 'member', label: 'Mitglied' }
];
describe('SegmentedControl', () => {
it('renders all option labels', () => {
render(SegmentedControl, { props: { options, value: 'planner', onchange: vi.fn() } });
expect(screen.getByRole('button', { name: 'Planer' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Mitglied' })).toBeInTheDocument();
});
it('marks the active option with aria-pressed', () => {
render(SegmentedControl, { props: { options, value: 'planner', onchange: vi.fn() } });
expect(screen.getByRole('button', { name: 'Planer' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Mitglied' })).toHaveAttribute('aria-pressed', 'false');
});
it('calls onchange with the new value when an option is clicked', async () => {
const onchange = vi.fn();
render(SegmentedControl, { props: { options, value: 'planner', onchange } });
await userEvent.click(screen.getByRole('button', { name: 'Mitglied' }));
expect(onchange).toHaveBeenCalledWith('member');
});
});

View File

@@ -0,0 +1,31 @@
<script lang="ts">
const { message, visible, ondismiss }: {
message: string;
visible: boolean;
ondismiss?: () => void;
} = $props();
</script>
{#if visible}
<div
role="status"
style="
position: fixed;
bottom: 24px;
right: 24px;
z-index: 200;
background: var(--color-surface);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-overlay);
border-radius: var(--radius-lg);
color: var(--color-text);
padding: 12px 16px;
display: flex;
align-items: center;
gap: 12px;
"
>
<span>{message}</span>
<button aria-label="Schließen" onclick={ondismiss}>✕</button>
</div>
{/if}

View File

@@ -0,0 +1,23 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import Toast from './Toast.svelte';
describe('Toast', () => {
it('is not mounted when visible is false', () => {
render(Toast, { props: { message: 'Hallo', visible: false } });
expect(screen.queryByRole('status')).toBeNull();
});
it('shows the message when visible is true', () => {
render(Toast, { props: { message: 'Gespeichert', visible: true } });
expect(screen.getByRole('status')).toHaveTextContent('Gespeichert');
});
it('calls ondismiss when close button is clicked', async () => {
const ondismiss = vi.fn();
render(Toast, { props: { message: 'Fehler', visible: true, ondismiss } });
await userEvent.click(screen.getByRole('button', { name: /schließen/i }));
expect(ondismiss).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,18 @@
import type { PageServerLoad } from './$types';
import { apiClient } from '$lib/server/api';
export const load: PageServerLoad = async ({ fetch, locals }) => {
const api = apiClient(fetch);
const [membersRes, inviteRes] = await Promise.all([
api.GET('/v1/households/mine/members'),
api.GET('/v1/households/mine/invites')
]);
return {
members: membersRes.data ?? [],
currentUserId: locals.benutzer!.id,
activeInvite: inviteRes.data?.data ?? null,
householdName: locals.haushalt?.name ?? ''
};
};

View File

@@ -1 +1,114 @@
<h1 class="text-2xl font-medium p-6">Mitglieder</h1> <script lang="ts">
import { untrack } from 'svelte';
import MemberGrid from './MemberGrid.svelte';
import InvitePanel from './InvitePanel.svelte';
import RemoveDialog from './RemoveDialog.svelte';
import Toast from '$lib/components/Toast.svelte';
let { data } = $props();
let members = $state(untrack(() => data.members as { userId: string; displayName: string; role: string; joinedAt: string }[]));
let activeInvite = $state(untrack(() => data.activeInvite as { inviteCode: string; shareUrl: string; expiresAt: string } | null));
let showInvitePanel = $state(false);
let removeTarget: { userId: string; displayName: string; role: string; joinedAt: string } | null = $state(null);
let toastMessage = $state('');
let toastVisible = $state(false);
const currentUserRole = $derived(members.find((m) => m.userId === data.currentUserId)?.role ?? 'member');
const isPlanner = $derived(currentUserRole === 'planner');
function showToast(message: string) {
toastMessage = message;
toastVisible = true;
}
async function handleRoleChange(
member: { userId: string; displayName: string; role: string; joinedAt: string },
newRole: string
) {
const original = members.slice();
members = members.map((m) => (m.userId === member.userId ? { ...m, role: newRole } : m));
const res = await fetch('/members/' + member.userId, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: newRole })
});
if (!res.ok) {
members = original;
showToast('Fehler beim Ändern der Rolle');
}
}
function handleRemove(member: { userId: string; displayName: string; role: string; joinedAt: string }) {
removeTarget = member;
}
async function handleConfirmRemove() {
if (!removeTarget) return;
const target = removeTarget;
const original = members.slice();
removeTarget = null;
members = members.filter((m) => m.userId !== target.userId);
const res = await fetch('/members/' + target.userId, { method: 'DELETE' });
if (!res.ok) {
members = original;
showToast('Fehler beim Entfernen des Mitglieds');
}
}
async function handleInviteClick() {
if (!activeInvite) {
const res = await fetch('/members/invites', { method: 'POST' });
if (res.ok) {
activeInvite = await res.json();
} else {
showToast('Einladung konnte nicht erstellt werden');
return;
}
}
showInvitePanel = !showInvitePanel;
}
async function handleRegenerate() {
const res = await fetch('/members/invites', { method: 'POST' });
if (res.ok) {
activeInvite = await res.json();
} else {
showToast('Link konnte nicht erneuert werden');
}
}
</script>
<svelte:head><title>Mitglieder — Mealprep</title></svelte:head>
<div class="p-[16px_20px] md:p-[40px_56px]">
<h1 class="font-[var(--font-display)] text-[28px] font-medium tracking-[-0.02em] mb-1 text-[var(--color-text)]">Mitglieder</h1>
<p class="text-[13px] text-[var(--color-text-muted)] mb-8">{members.length} Mitglieder{data.householdName ? ` · ${data.householdName}` : ''}</p>
<MemberGrid
{members}
currentUserId={data.currentUserId}
{isPlanner}
showInviteCard={isPlanner}
onremove={handleRemove}
onrolechange={handleRoleChange}
oninviteclick={handleInviteClick}
/>
{#if showInvitePanel && isPlanner && activeInvite}
<InvitePanel invite={activeInvite} onregenerate={handleRegenerate} />
{/if}
<RemoveDialog
show={removeTarget !== null}
member={removeTarget ?? { userId: '', displayName: '', role: '', joinedAt: '' }}
onconfirm={handleConfirmRemove}
oncancel={() => (removeTarget = null)}
/>
<Toast message={toastMessage} visible={toastVisible} ondismiss={() => (toastVisible = false)} />
</div>

View File

@@ -0,0 +1,84 @@
<script lang="ts">
let {
onclick
}: {
onclick: () => void;
} = $props();
</script>
<button
type="button"
data-testid="invite-card"
{onclick}
class="invite-card"
>
<div class="invite-plus">+</div>
<div class="invite-label">Mitglied einladen</div>
</button>
<style>
.invite-card {
background: white;
border: 1.5px dashed var(--color-border);
border-radius: var(--radius-xl);
padding: 24px 20px 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
cursor: pointer;
min-height: 180px;
gap: 10px;
width: 100%;
}
.invite-card:hover {
border-color: var(--green-light);
background: var(--green-tint);
}
.invite-plus {
width: 44px;
height: 44px;
border-radius: var(--radius-full);
background: var(--color-subtle);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: var(--color-text-muted);
}
.invite-card:hover .invite-plus {
background: var(--green-light);
color: var(--green-dark);
}
.invite-label {
font-size: 13px;
font-weight: 500;
color: var(--color-text-muted);
}
.invite-card:hover .invite-label {
color: var(--green-dark);
}
@media (max-width: 768px) {
.invite-card {
padding: 16px;
min-height: 120px;
}
.invite-plus {
width: 36px;
height: 36px;
font-size: 18px;
}
.invite-label {
font-size: 11px;
}
}
</style>

View File

@@ -0,0 +1,23 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import InviteCard from './InviteCard.svelte';
describe('InviteCard', () => {
it('renders the invite tile', () => {
render(InviteCard, { props: { onclick: vi.fn() } });
expect(screen.getByTestId('invite-card')).toBeInTheDocument();
});
it('shows a descriptive label', () => {
render(InviteCard, { props: { onclick: vi.fn() } });
expect(screen.getByText(/einladen/i)).toBeInTheDocument();
});
it('calls onclick when tile is clicked', async () => {
const onclick = vi.fn();
render(InviteCard, { props: { onclick } });
await userEvent.click(screen.getByTestId('invite-card'));
expect(onclick).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,139 @@
<script lang="ts">
let {
invite,
onregenerate
}: {
invite: { inviteCode: string; shareUrl: string; expiresAt: string };
onregenerate: () => void;
} = $props();
let copied = $state(false);
function copy() {
if (navigator.clipboard) {
navigator.clipboard.writeText(invite.shareUrl);
}
copied = true;
setTimeout(() => {
copied = false;
}, 2000);
}
function formatExpiry(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
const isExpiringSoon = $derived(
new Date(invite.expiresAt).getTime() - Date.now() <= 24 * 60 * 60 * 1000
);
</script>
<div class="invite-panel">
<div class="invite-panel-title">Einladelink teilen</div>
<div class="invite-panel-desc">Wer diesen Link öffnet, kann dem Haushalt als Mitglied beitreten.</div>
<div class="invite-link-row">
<div class="invite-link-box">{invite.shareUrl || invite.inviteCode}</div>
<button type="button" data-testid="copy-btn" class="btn-copy" onclick={copy}>
{copied ? 'Kopiert ✓' : 'Kopieren'}
</button>
</div>
<div class="invite-expiry">
Läuft ab: <span class:yellow={isExpiringSoon}>{formatExpiry(invite.expiresAt)}</span>
</div>
<button type="button" data-testid="regenerate-btn" class="btn-regen" onclick={onregenerate}>
Neuen Link generieren
</button>
</div>
<style>
.invite-panel {
background: white;
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: 24px;
margin-top: 8px;
}
.invite-panel-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
}
.invite-panel-desc {
font-size: 12px;
color: var(--color-text-muted);
margin-bottom: 16px;
}
.invite-link-row {
display: flex;
gap: 8px;
align-items: center;
}
.invite-link-box {
flex: 1;
background: var(--color-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 8px 12px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-copy {
padding: 8px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: white;
font-size: 12px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
}
.btn-copy:hover {
background: var(--color-subtle);
}
.invite-expiry {
font-size: 11px;
color: var(--color-text-muted);
margin-top: 8px;
}
.invite-expiry span {
font-weight: 500;
}
.invite-expiry span.yellow {
background: var(--yellow-tint);
color: var(--yellow-text);
padding: 1px 6px;
border-radius: var(--radius-sm);
}
.btn-regen {
margin-top: 12px;
font-size: 12px;
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
display: block;
}
.btn-regen:hover {
color: var(--color-text);
}
</style>

View File

@@ -0,0 +1,39 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import InvitePanel from './InvitePanel.svelte';
const invite = {
inviteCode: 'ABC123XY',
shareUrl: 'https://example.com/join/ABC123XY',
expiresAt: '2026-12-01T00:00:00Z'
};
describe('InvitePanel', () => {
it('shows the invite URL', () => {
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
expect(screen.getByText(/ABC123XY/)).toBeInTheDocument();
});
it('has a copy button', () => {
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
expect(screen.getByTestId('copy-btn')).toBeInTheDocument();
});
it('has a regenerate button', () => {
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
expect(screen.getByTestId('regenerate-btn')).toBeInTheDocument();
});
it('calls onregenerate when regenerate button is clicked', async () => {
const onregenerate = vi.fn();
render(InvitePanel, { props: { invite, onregenerate } });
await userEvent.click(screen.getByTestId('regenerate-btn'));
expect(onregenerate).toHaveBeenCalledOnce();
});
it('shows the panel title', () => {
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
expect(screen.getByText('Einladelink teilen')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,386 @@
<script lang="ts">
import { browser } from '$app/environment';
type Member = {
userId: string;
displayName: string;
role: string;
joinedAt: string;
};
let {
member,
isCurrentUser,
isPlanner,
onremove,
onrolechange
}: {
member: Member;
isCurrentUser: boolean;
isPlanner: boolean;
onremove: (member: Member) => void;
onrolechange: (member: Member, newRole: string) => void;
} = $props();
let menuOpen = $state(false);
let editingRole = $state(false);
const initials = $derived(member.displayName.slice(0, 2).toUpperCase());
const avatarBg = $derived(member.role === 'planner' ? 'var(--green-dark)' : 'var(--blue)');
const joinDateFormatted = $derived(
new Date(member.joinedAt).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
);
let cardEl: HTMLElement | undefined = $state(undefined);
$effect(() => {
if (!browser || !menuOpen) return;
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
menuOpen = false;
}
}
document.addEventListener('keydown', onKeydown);
return () => document.removeEventListener('keydown', onKeydown);
});
$effect(() => {
if (!browser || !menuOpen) return;
function onClickAway(e: MouseEvent) {
if (cardEl && !cardEl.contains(e.target as Node)) {
menuOpen = false;
}
}
document.addEventListener('click', onClickAway);
return () => document.removeEventListener('click', onClickAway);
});
</script>
<article
bind:this={cardEl}
data-testid="member-card"
class="member-card"
class:own={isCurrentUser}
class:editing={editingRole}
>
<!-- Avatar -->
<div class="avatar" style="background: {avatarBg};">
{initials}
</div>
<!-- Name -->
<div class="member-name">{member.displayName}</div>
<!-- Role badge or inline role control -->
{#if editingRole}
<div role="group" class="role-control">
<button
type="button"
class="role-control-btn"
class:active={member.role === 'planner'}
onclick={() => {
if (member.role !== 'planner') {
onrolechange(member, 'planner');
editingRole = false;
}
}}
>Planer</button>
<button
type="button"
class="role-control-btn"
class:active={member.role === 'member'}
onclick={() => {
if (member.role !== 'member') {
onrolechange(member, 'member');
editingRole = false;
}
}}
>Mitglied</button>
</div>
{:else}
<span class="role-badge" class:planer={member.role === 'planner'} class:mitglied={member.role === 'member'}>
{member.role === 'planner' ? 'Planer' : 'Mitglied'}
</span>
{/if}
<!-- Join date -->
<div class="join-date">seit {joinDateFormatted}</div>
<!-- Du badge (own card) or Abbrechen (when editing role) -->
{#if isCurrentUser}
<div class="self-badge-wrap">
<span class="self-badge">Du</span>
</div>
{:else if editingRole}
<button
type="button"
class="cancel-btn"
onclick={() => { editingRole = false; }}
>Abbrechen</button>
{/if}
<!-- Kebab button -->
{#if isPlanner && !isCurrentUser && !editingRole}
<button
data-testid="kebab-btn"
type="button"
class="kebab-btn"
onclick={() => { menuOpen = true; }}
aria-label="Optionen"
></button>
<!-- Dropdown -->
{#if menuOpen}
<div class="dropdown">
<button
type="button"
class="dropdown-item"
onclick={() => { menuOpen = false; editingRole = true; }}
><span class="dropdown-icon">🔄</span>Rolle ändern</button>
<div class="dropdown-divider"></div>
<button
type="button"
class="dropdown-item danger"
onclick={() => { menuOpen = false; onremove(member); }}
><span class="dropdown-icon"></span>Entfernen</button>
</div>
{/if}
{/if}
</article>
<style>
.member-card {
background: white;
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: 24px 20px 20px;
box-shadow: var(--shadow-card);
position: relative;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
min-height: 180px;
}
.member-card.own {
border-color: var(--green-light);
}
.member-card.editing {
border-color: #B5D4F4;
}
.avatar {
width: 56px;
height: 56px;
border-radius: 50%;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-size: 20px;
font-weight: 500;
margin-bottom: 12px;
flex-shrink: 0;
}
.member-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 6px;
}
.role-badge {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: var(--radius-full);
white-space: nowrap;
}
.role-badge.planer {
background: var(--green-tint);
color: var(--green-dark);
}
.role-badge.mitglied {
background: var(--blue-tint);
color: var(--blue-dark);
}
.join-date {
font-size: 11px;
color: var(--color-text-muted);
margin-top: 8px;
}
.self-badge-wrap {
margin-top: 8px;
}
.self-badge {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: var(--radius-full);
background: var(--green-tint);
color: var(--green-dark);
}
.cancel-btn {
margin-top: 8px;
font-size: 11px;
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
}
.kebab-btn {
position: absolute;
top: 12px;
right: 12px;
width: 28px;
height: 28px;
border-radius: var(--radius-md);
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: var(--color-text-muted);
opacity: 0;
}
.kebab-btn:hover {
background: var(--color-subtle);
color: var(--color-text);
}
@media (hover: none) {
.kebab-btn {
opacity: 1;
}
}
.member-card:hover .kebab-btn,
.member-card:focus-within .kebab-btn {
opacity: 1;
}
.dropdown {
position: absolute;
top: 44px;
right: 12px;
background: white;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-raised);
min-width: 160px;
z-index: 10;
overflow: hidden;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
font-size: 13px;
color: var(--color-text);
cursor: pointer;
white-space: nowrap;
width: 100%;
background: none;
border: none;
text-align: left;
}
.dropdown-item:hover {
background: var(--color-subtle);
}
.dropdown-item.danger {
color: var(--color-error);
}
.dropdown-item.danger:hover {
background: var(--error-tint, #FDECEA);
}
.dropdown-icon {
font-size: 14px;
width: 16px;
text-align: center;
}
.dropdown-divider {
height: 1px;
background: var(--color-border);
margin: 2px 0;
}
.role-control {
display: flex;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
margin-top: 8px;
width: 100%;
}
.role-control-btn {
flex: 1;
padding: 6px 8px;
font-size: 11px;
font-weight: 500;
background: white;
border: none;
cursor: pointer;
color: var(--color-text-muted);
}
.role-control-btn:first-child {
border-right: 1px solid var(--color-border);
}
.role-control-btn.active {
background: var(--green-dark);
color: white;
}
@media (max-width: 768px) {
.member-card {
padding: 16px;
min-height: auto;
}
.avatar {
width: 44px;
height: 44px;
font-size: 16px;
margin-bottom: 8px;
}
.member-name {
font-size: 12px;
}
}
</style>

View File

@@ -0,0 +1,175 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import MemberCard from './MemberCard.svelte';
const plannerMember = {
userId: 'u1',
displayName: 'Sarah',
role: 'planner',
joinedAt: '2024-01-01T00:00:00Z'
};
const regularMember = {
userId: 'u2',
displayName: 'Tom',
role: 'member',
joinedAt: '2024-02-01T00:00:00Z'
};
describe('MemberCard', () => {
it('shows the member display name', () => {
render(MemberCard, {
props: {
member: plannerMember,
isCurrentUser: false,
isPlanner: false,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.getByText('Sarah')).toBeInTheDocument();
});
it('shows "Du"-badge when isCurrentUser is true', () => {
render(MemberCard, {
props: {
member: plannerMember,
isCurrentUser: true,
isPlanner: false,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.getByText('Du')).toBeInTheDocument();
});
it('does not show kebab button when isCurrentUser is true', () => {
render(MemberCard, {
props: {
member: plannerMember,
isCurrentUser: true,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.queryByTestId('kebab-btn')).toBeNull();
});
it('does not show kebab button when viewer is not a planner', () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: false,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.queryByTestId('kebab-btn')).toBeNull();
});
it('shows kebab button for other members when viewer is planner', () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.getByTestId('kebab-btn')).toBeInTheDocument();
});
it('opens dropdown when kebab is clicked', async () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
await userEvent.click(screen.getByTestId('kebab-btn'));
expect(screen.getByText('Rolle ändern')).toBeInTheDocument();
expect(screen.getByText('Entfernen')).toBeInTheDocument();
});
it('calls onremove when "Entfernen" is clicked in dropdown', async () => {
const onremove = vi.fn();
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove,
onrolechange: vi.fn()
}
});
await userEvent.click(screen.getByTestId('kebab-btn'));
await userEvent.click(screen.getByText('Entfernen'));
expect(onremove).toHaveBeenCalledWith(regularMember);
});
it('shows SegmentedControl when "Rolle ändern" is clicked', async () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
await userEvent.click(screen.getByTestId('kebab-btn'));
await userEvent.click(screen.getByText('Rolle ändern'));
expect(screen.getByRole('group')).toBeInTheDocument();
});
it('closes dropdown on Escape key', async () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
await userEvent.click(screen.getByTestId('kebab-btn'));
expect(screen.getByText('Entfernen')).toBeInTheDocument();
await userEvent.keyboard('{Escape}');
expect(screen.queryByText('Entfernen')).toBeNull();
});
it('shows formatted join date', () => {
render(MemberCard, {
props: {
member: plannerMember,
isCurrentUser: false,
isPlanner: false,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.getByText(/seit 01\.01\.2024/)).toBeInTheDocument();
});
it('shows Abbrechen button when editing role', async () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
await userEvent.click(screen.getByTestId('kebab-btn'));
await userEvent.click(screen.getByText('Rolle ändern'));
expect(screen.getByText(/abbrechen/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import MemberCard from './MemberCard.svelte';
import InviteCard from './InviteCard.svelte';
type Member = {
userId: string;
displayName: string;
role: string;
joinedAt: string;
};
let {
members,
currentUserId,
isPlanner,
showInviteCard,
onremove,
onrolechange,
oninviteclick
}: {
members: Member[];
currentUserId: string;
isPlanner: boolean;
showInviteCard: boolean;
onremove: (member: any) => void;
onrolechange: (member: any, newRole: string) => void;
oninviteclick: () => void;
} = $props();
const sortedMembers = $derived(
[...members].sort((a, b) => {
if (a.userId === currentUserId) return -1;
if (b.userId === currentUserId) return 1;
return new Date(a.joinedAt).getTime() - new Date(b.joinedAt).getTime();
})
);
</script>
<div class="member-grid">
{#each sortedMembers as m (m.userId)}
<MemberCard
member={m}
isCurrentUser={m.userId === currentUserId}
{isPlanner}
{onremove}
onrolechange={(m, role) => onrolechange(m, role)}
/>
{/each}
{#if isPlanner && showInviteCard}
<InviteCard onclick={oninviteclick} />
{/if}
</div>
<style>
.member-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
@media (max-width: 768px) {
.member-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
}
</style>

View File

@@ -0,0 +1,73 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import MemberGrid from './MemberGrid.svelte';
const members = [
{ userId: 'u1', displayName: 'Sarah', role: 'planner', joinedAt: '2024-01-01T00:00:00Z' },
{ userId: 'u2', displayName: 'Tom', role: 'member', joinedAt: '2024-02-01T00:00:00Z' },
{ userId: 'u3', displayName: 'Anna', role: 'member', joinedAt: '2024-03-01T00:00:00Z' }
];
describe('MemberGrid', () => {
it('renders all member cards', () => {
render(MemberGrid, {
props: {
members,
currentUserId: 'u1',
isPlanner: true,
showInviteCard: true,
onremove: vi.fn(),
onrolechange: vi.fn(),
oninviteclick: vi.fn()
}
});
expect(screen.getByText('Sarah')).toBeInTheDocument();
expect(screen.getByText('Tom')).toBeInTheDocument();
expect(screen.getByText('Anna')).toBeInTheDocument();
});
it('shows invite card when showInviteCard is true and isPlanner is true', () => {
render(MemberGrid, {
props: {
members,
currentUserId: 'u1',
isPlanner: true,
showInviteCard: true,
onremove: vi.fn(),
onrolechange: vi.fn(),
oninviteclick: vi.fn()
}
});
expect(screen.getByTestId('invite-card')).toBeInTheDocument();
});
it('hides invite card when isPlanner is false', () => {
render(MemberGrid, {
props: {
members,
currentUserId: 'u2',
isPlanner: false,
showInviteCard: true,
onremove: vi.fn(),
onrolechange: vi.fn(),
oninviteclick: vi.fn()
}
});
expect(screen.queryByTestId('invite-card')).toBeNull();
});
it('shows "Du"-badge on the current user card', () => {
render(MemberGrid, {
props: {
members,
currentUserId: 'u1',
isPlanner: true,
showInviteCard: false,
onremove: vi.fn(),
onrolechange: vi.fn(),
oninviteclick: vi.fn()
}
});
expect(screen.getByText('Du')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,86 @@
<script lang="ts">
import BottomSheet from '$lib/components/BottomSheet.svelte';
let {
show,
member,
onconfirm,
oncancel
}: {
show: boolean;
member: { userId: string; displayName: string; role: string; joinedAt: string };
onconfirm: () => void;
oncancel: () => void;
} = $props();
const isMobile = () => typeof window !== 'undefined' && window.innerWidth < 768;
</script>
{#if show}
{#if isMobile()}
<BottomSheet open={show} onclose={oncancel}>
<div data-testid="remove-dialog" style="padding: 24px 24px 32px;">
<h2 style="margin: 0 0 8px; font-size: 15px; font-weight: 500;">Mitglied entfernen?</h2>
<p style="font-size: 12px; color: var(--color-text-muted); line-height: 1.6; margin-bottom: 24px;">
<strong style="color: var(--color-text); font-weight: 500;">{member.displayName}</strong> wird aus dem Haushalt entfernt.
</p>
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button
type="button"
onclick={oncancel}
style="padding: 9px 18px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: white; font-size: 12px; font-weight: 500; cursor: pointer;"
>
Abbrechen
</button>
<button
type="button"
data-testid="confirm-remove-btn"
onclick={onconfirm}
style="padding: 9px 18px; border-radius: var(--radius-md); border: none; background: var(--color-error); color: white; font-size: 12px; font-weight: 500; cursor: pointer;"
>
Entfernen
</button>
</div>
</div>
</BottomSheet>
{:else}
<div
data-testid="dialog-backdrop"
role="presentation"
style="position: fixed; inset: 0; z-index: 100; background: rgba(28,28,24,.45); display: flex; align-items: center; justify-content: center;"
>
<div
data-testid="remove-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="remove-dialog-title"
tabindex="-1"
style="background: white; border-radius: var(--radius-xl); padding: 28px 32px; max-width: 380px; width: 100%; box-shadow: var(--shadow-raised);"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<h2 id="remove-dialog-title" style="font-size: 16px; font-weight: 500; margin: 0 0 8px;">Mitglied entfernen?</h2>
<p style="font-size: 13px; color: var(--color-text-muted); line-height: 1.6; margin-bottom: 24px;">
<strong style="color: var(--color-text); font-weight: 500;">{member.displayName}</strong> wird aus dem Haushalt entfernt und verliert sofort den Zugang zu allen Plänen und Rezepten.
</p>
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button
type="button"
onclick={oncancel}
style="padding: 9px 18px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: white; font-size: 13px; font-weight: 500; cursor: pointer;"
>
Abbrechen
</button>
<button
type="button"
data-testid="confirm-remove-btn"
onclick={onconfirm}
style="padding: 9px 18px; border-radius: var(--radius-md); border: none; background: var(--color-error); color: white; font-size: 13px; font-weight: 500; cursor: pointer;"
>
Entfernen
</button>
</div>
</div>
</div>
{/if}
{/if}

View File

@@ -0,0 +1,56 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import RemoveDialog from './RemoveDialog.svelte';
const member = {
userId: 'u2',
displayName: 'Tom',
role: 'member',
joinedAt: '2024-02-01T00:00:00Z'
};
describe('RemoveDialog', () => {
it('is not rendered when show is false', () => {
render(RemoveDialog, {
props: { show: false, member, onconfirm: vi.fn(), oncancel: vi.fn() }
});
expect(screen.queryByTestId('remove-dialog')).toBeNull();
});
it('shows the member displayName in dialog', () => {
render(RemoveDialog, {
props: { show: true, member, onconfirm: vi.fn(), oncancel: vi.fn() }
});
expect(screen.getByTestId('remove-dialog')).toBeInTheDocument();
expect(screen.getByText(/Tom/)).toBeInTheDocument();
});
it('calls onconfirm when confirm button is clicked', async () => {
const onconfirm = vi.fn();
render(RemoveDialog, {
props: { show: true, member, onconfirm, oncancel: vi.fn() }
});
await userEvent.click(screen.getByTestId('confirm-remove-btn'));
expect(onconfirm).toHaveBeenCalledOnce();
});
it('calls oncancel when cancel button is clicked', async () => {
const oncancel = vi.fn();
render(RemoveDialog, {
props: { show: true, member, onconfirm: vi.fn(), oncancel }
});
await userEvent.click(screen.getByRole('button', { name: /abbrechen/i }));
expect(oncancel).toHaveBeenCalledOnce();
});
it('does NOT call oncancel when backdrop is clicked', async () => {
const oncancel = vi.fn();
render(RemoveDialog, {
props: { show: true, member, onconfirm: vi.fn(), oncancel }
});
const backdrop = screen.getByTestId('dialog-backdrop');
await userEvent.click(backdrop);
expect(oncancel).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,21 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiClient } from '$lib/server/api';
export const DELETE: RequestHandler = async ({ fetch, params }) => {
const api = apiClient(fetch);
const { response } = await api.DELETE('/v1/households/mine/members/{userId}', {
params: { path: { userId: params.userId } }
});
return new Response(null, { status: response?.status ?? 204 });
};
export const PATCH: RequestHandler = async ({ fetch, params, request }) => {
const body = await request.json();
const api = apiClient(fetch);
const { data, response } = await api.PATCH('/v1/households/mine/members/{userId}', {
params: { path: { userId: params.userId } },
body
});
return json(data, { status: response?.status ?? 200 });
};

View File

@@ -0,0 +1,50 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } }));
const mockDelete = vi.fn();
const mockPatch = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ DELETE: mockDelete, PATCH: mockPatch })
}));
const USER_UUID = '22222222-2222-2222-2222-222222222222';
describe('members server routes', () => {
let DELETE: any;
let PATCH: any;
beforeEach(async () => {
mockDelete.mockReset();
mockPatch.mockReset();
vi.resetModules();
const mod = await import('./+server');
DELETE = mod.DELETE;
PATCH = mod.PATCH;
});
it('DELETE proxies to backend and returns 204', async () => {
mockDelete.mockResolvedValue({ response: { status: 204 } });
const event = {
fetch: vi.fn(),
params: { userId: USER_UUID },
request: { json: vi.fn() }
} as any;
const res = await DELETE(event);
expect(res.status).toBe(204);
});
it('PATCH proxies to backend and returns member response', async () => {
mockPatch.mockResolvedValue({
data: { status: 'success', data: { userId: USER_UUID, displayName: 'Tom', role: 'planner', joinedAt: '' } },
response: { status: 200 }
});
const event = {
fetch: vi.fn(),
params: { userId: USER_UUID },
request: { json: async () => ({ role: 'planner' }) }
} as any;
const res = await PATCH(event);
expect(res.status).toBe(200);
});
});

View File

@@ -0,0 +1,9 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiClient } from '$lib/server/api';
export const POST: RequestHandler = async ({ fetch }) => {
const api = apiClient(fetch);
const { data, response } = await api.POST('/v1/households/mine/invites');
return json(data?.data, { status: response?.status ?? 201 });
};

View File

@@ -0,0 +1,33 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } }));
const mockPost = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ POST: mockPost })
}));
describe('invites server route', () => {
let POST: any;
beforeEach(async () => {
mockPost.mockReset();
vi.resetModules();
const mod = await import('./+server');
POST = mod.POST;
});
it('POST returns unwrapped InviteResponse', async () => {
const invite = { inviteCode: 'ABC123', shareUrl: 'https://x.com/join/ABC123', expiresAt: '2026-12-01T00:00:00Z' };
mockPost.mockResolvedValue({
data: { status: 'success', data: invite },
response: { status: 200 }
});
const event = { fetch: vi.fn() } as any;
const res = await POST(event);
const body = await res.json();
expect(body.inviteCode).toBe('ABC123');
expect(body.shareUrl).toBe('https://x.com/join/ABC123');
expect(body.expiresAt).toBe('2026-12-01T00:00:00Z');
});
});

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/server/api', () => ({
apiClient: vi.fn(() => ({
GET: vi.fn()
}))
}));
vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } }));
describe('members page.server load', () => {
let load: any;
beforeEach(async () => {
vi.resetModules();
const mod = await import('./+page.server');
load = mod.load;
});
it('returns members and currentUserId', async () => {
const mockGet = vi.fn().mockImplementation((path: string) => {
if (path === '/v1/households/mine/members') {
return {
data: [
{ userId: 'u1', displayName: 'Sarah', role: 'planner', joinedAt: '2024-01-01T00:00:00Z' },
{ userId: 'u2', displayName: 'Tom', role: 'member', joinedAt: '2024-02-01T00:00:00Z' }
]
};
}
if (path === '/v1/households/mine/invites') {
return {
data: {
data: {
inviteCode: 'ABC123',
shareUrl: 'https://x.com/join/ABC123',
expiresAt: '2024-12-01T00:00:00Z'
}
}
};
}
return { data: null };
});
const { apiClient } = await import('$lib/server/api');
(apiClient as ReturnType<typeof vi.fn>).mockReturnValue({ GET: mockGet });
const result = await load({
fetch: vi.fn(),
locals: { benutzer: { id: 'u1', name: 'Sarah', email: 'sarah@example.com' }, haushalt: {} }
} as any);
expect(result.members).toHaveLength(2);
expect(result.currentUserId).toBe('u1');
expect(result.activeInvite).toBeDefined();
});
it('returns null activeInvite when no active invite exists', async () => {
const mockGet = vi.fn().mockImplementation((path: string) => {
if (path === '/v1/households/mine/members') return { data: [] };
if (path === '/v1/households/mine/invites') return { data: null };
return { data: null };
});
const { apiClient } = await import('$lib/server/api');
(apiClient as ReturnType<typeof vi.fn>).mockReturnValue({ GET: mockGet });
const result = await load({
fetch: vi.fn(),
locals: { benutzer: { id: 'u1', name: 'Sarah', email: 'sarah@example.com' }, haushalt: {} }
} as any);
expect(result.activeInvite).toBeNull();
});
});