From d1e4b6c49e838121f4f9e33940d81192b18d8fab Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 18:41:38 +0200 Subject: [PATCH 01/10] feat(members): implement DELETE/PATCH member + GET invites backend endpoints - Add V006 migration: invalidated_at column + partial unique index on household_invite - Add findByHouseholdIdAndInvalidatedAtIsNull, findByHouseholdIdAndUserId, countByHouseholdIdAndRole - Add ChangeRoleRequest DTO - HouseholdService: getActiveInvite, createInvite (regenerate), removeMember, changeMemberRole - HouseholdController: GET /v1/households/mine/invites, DELETE/PATCH /v1/households/mine/members/{userId} Co-Authored-By: Claude Sonnet 4.6 --- .../household/HouseholdController.java | 25 +++ .../household/HouseholdInviteRepository.java | 1 + .../household/HouseholdMemberRepository.java | 2 + .../recipeapp/household/HouseholdService.java | 69 ++++++- .../household/dto/ChangeRoleRequest.java | 10 + .../household/entity/HouseholdInvite.java | 5 + .../V006__add_invite_invalidated_at.sql | 6 + .../household/HouseholdControllerTest.java | 54 ++++++ .../household/HouseholdServiceTest.java | 181 ++++++++++++++++++ 9 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/java/com/recipeapp/household/dto/ChangeRoleRequest.java create mode 100644 backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdController.java b/backend/src/main/java/com/recipeapp/household/HouseholdController.java index 7f704d7..46c8556 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdController.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdController.java @@ -9,6 +9,8 @@ import org.springframework.web.bind.annotation.*; import java.security.Principal; import java.util.List; +import java.util.Optional; +import java.util.UUID; @RestController @RequestMapping("/v1") @@ -40,12 +42,35 @@ public class HouseholdController { return ResponseEntity.ok(members); } + @GetMapping("/households/mine/invites") + public ResponseEntity> getActiveInvite(Principal principal) { + Optional invite = householdService.getActiveInvite(principal.getName()); + return invite + .map(r -> ResponseEntity.ok(ApiResponse.success(r))) + .orElse(ResponseEntity.noContent().build()); + } + @PostMapping("/households/mine/invites") public ResponseEntity> createInvite(Principal principal) { InviteResponse response = householdService.createInvite(principal.getName()); return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response)); } + @DeleteMapping("/households/mine/members/{userId}") + public ResponseEntity removeMember(Principal principal, @PathVariable UUID userId) { + householdService.removeMember(principal.getName(), userId); + return ResponseEntity.noContent().build(); + } + + @PatchMapping("/households/mine/members/{userId}") + public ResponseEntity> 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") public ResponseEntity> acceptInvite( Principal principal, diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdInviteRepository.java b/backend/src/main/java/com/recipeapp/household/HouseholdInviteRepository.java index 7486172..03dd511 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdInviteRepository.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdInviteRepository.java @@ -8,4 +8,5 @@ import java.util.UUID; public interface HouseholdInviteRepository extends JpaRepository { Optional findByInviteCode(String inviteCode); + Optional findByHouseholdIdAndInvalidatedAtIsNull(UUID householdId); } diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java b/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java index 80853b1..32a24de 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java @@ -10,4 +10,6 @@ import java.util.UUID; public interface HouseholdMemberRepository extends JpaRepository { Optional findByUserEmailIgnoreCase(String email); List findByHouseholdId(UUID householdId); + Optional findByHouseholdIdAndUserId(UUID householdId, UUID userId); + long countByHouseholdIdAndRole(UUID householdId, String role); } diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdService.java b/backend/src/main/java/com/recipeapp/household/HouseholdService.java index 242f1d5..678bf67 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdService.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdService.java @@ -23,6 +23,8 @@ import org.springframework.transaction.annotation.Transactional; import java.security.SecureRandom; import java.time.Instant; import java.util.List; +import java.util.Optional; +import java.util.UUID; @Service public class HouseholdService { @@ -91,21 +93,73 @@ public class HouseholdService { .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"); + } + + householdMemberRepository.delete(target); + } + + @Transactional(readOnly = true) + public Optional getActiveInvite(String userEmail) { + HouseholdMember member = findMembership(userEmail); + return householdInviteRepository + .findByHouseholdIdAndInvalidatedAtIsNull(member.getHousehold().getId()) + .filter(invite -> invite.getExpiresAt().isAfter(Instant.now())) + .map(this::toInviteResponse); + } + @Transactional public InviteResponse createInvite(String userEmail) { HouseholdMember member = findMembership(userEmail); Household household = member.getHousehold(); + householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(household.getId()) + .ifPresent(existing -> { + existing.setInvalidatedAt(Instant.now()); + householdInviteRepository.save(existing); + }); + String code = generateInviteCode(); Instant expiresAt = Instant.now().plusSeconds(48 * 3600); HouseholdInvite invite = householdInviteRepository.save( new HouseholdInvite(household, code, expiresAt)); - return new InviteResponse( - invite.getInviteCode(), - "https://yourapp.com/join/" + invite.getInviteCode(), - invite.getExpiresAt()); + return toInviteResponse(invite); } @Transactional @@ -204,4 +258,11 @@ public class HouseholdService { member.getRole(), member.getJoinedAt()); } + + private InviteResponse toInviteResponse(HouseholdInvite invite) { + return new InviteResponse( + invite.getInviteCode(), + "https://yourapp.com/join/" + invite.getInviteCode(), + invite.getExpiresAt()); + } } diff --git a/backend/src/main/java/com/recipeapp/household/dto/ChangeRoleRequest.java b/backend/src/main/java/com/recipeapp/household/dto/ChangeRoleRequest.java new file mode 100644 index 0000000..80e9c95 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/household/dto/ChangeRoleRequest.java @@ -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 +) {} diff --git a/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java b/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java index 916fac1..04246e6 100644 --- a/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java +++ b/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java @@ -25,6 +25,9 @@ public class HouseholdInvite { @Column(name = "expires_at", nullable = false) private Instant expiresAt; + @Column(name = "invalidated_at") + private Instant invalidatedAt; + protected HouseholdInvite() {} public HouseholdInvite(Household household, String inviteCode, Instant expiresAt) { @@ -39,4 +42,6 @@ public class HouseholdInvite { public String getStatus() { return status; } public void setStatus(String status) { this.status = status; } public Instant getExpiresAt() { return expiresAt; } + public Instant getInvalidatedAt() { return invalidatedAt; } + public void setInvalidatedAt(Instant invalidatedAt) { this.invalidatedAt = invalidatedAt; } } diff --git a/backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql b/backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql new file mode 100644 index 0000000..c9a5511 --- /dev/null +++ b/backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql @@ -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; diff --git a/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java b/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java index ceee167..1a33db3 100644 --- a/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java +++ b/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java @@ -15,10 +15,12 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import java.time.Instant; import java.util.List; +import java.util.Optional; import java.util.UUID; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -103,6 +105,58 @@ class HouseholdControllerTest { .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 void acceptInviteShouldReturn200() throws Exception { var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member"); diff --git a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java index baf3c32..9b00fbb 100644 --- a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java +++ b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java @@ -22,6 +22,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.time.Instant; import java.util.List; import java.util.Optional; +import java.util.UUID; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -223,6 +224,169 @@ class HouseholdServiceTest { .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 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 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 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 result = householdService.getActiveInvite("sarah@example.com"); + + assertThat(result).isEmpty(); + } + @Test void getMembersShouldReturnAllMembers() { var user1 = testUser(); @@ -256,4 +420,21 @@ class HouseholdServiceTest { assertThatThrownBy(() -> householdService.createInvite("orphan@example.com")) .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)); + } } -- 2.49.1 From b04f2c51d2106624e2a4c18e67abf0b1a7002cbd Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 18:42:52 +0200 Subject: [PATCH 02/10] feat(members): update schema.d.ts with GET invites, DELETE/PATCH member types Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/api/schema.d.ts | 119 ++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/api/schema.d.ts b/frontend/src/lib/api/schema.d.ts index aadfed1..a6cc337 100644 --- a/frontend/src/lib/api/schema.d.ts +++ b/frontend/src/lib/api/schema.d.ts @@ -203,7 +203,7 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + get: operations["getActiveInvite"]; put?: never; post: operations["createInvite"]; delete?: never; @@ -212,6 +212,24 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -763,6 +781,14 @@ export interface components { /** Format: date-time */ joinedAt?: string; }; + ChangeRoleRequest: { + role: string; + }; + ApiResponseMemberResponse: { + status?: string; + data?: components["schemas"]["MemberResponse"]; + meta?: components["schemas"]["Meta"]; + }; ApiResponseInviteResponse: { status?: string; 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: { parameters: { query?: { -- 2.49.1 From 1b5704c8b5a919a311d051e73b0167139dce412b Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 19:01:08 +0200 Subject: [PATCH 03/10] =?UTF-8?q?feat(members):=20implement=20/members=20p?= =?UTF-8?q?age=20=E2=80=94=20Kachel-Ansicht=20(E2,=20issue=20#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Rename V006 migration to V026 (avoid conflict with existing V006) - Migration adds invalidated_at + partial unique index on household_invite Frontend: - Toast.svelte — new system component (message + dismiss) - SegmentedControl.svelte — new system component (options, value, onchange) - members/+page.server.ts — loads members + active invite - members/[userId]/+server.ts — DELETE/PATCH proxy - members/invites/+server.ts — POST (regenerate) proxy - MemberCard.svelte — tile with avatar, kebab, inline role edit - RemoveDialog.svelte — confirmation dialog (desktop modal + BottomSheet mobile) - InviteCard.svelte + InvitePanel.svelte — invite management UI - MemberGrid.svelte — responsive 4/2-col grid with sorted members - members/+page.svelte — page composing all components with optimistic updates Co-Authored-By: Claude Sonnet 4.6 --- ...ql => V026__add_invite_invalidated_at.sql} | 0 .../lib/components/SegmentedControl.svelte | 50 ++++ .../lib/components/SegmentedControl.test.ts | 30 +++ frontend/src/lib/components/Toast.svelte | 31 +++ frontend/src/lib/components/Toast.test.ts | 23 ++ .../src/routes/(app)/members/+page.server.ts | 17 ++ .../src/routes/(app)/members/+page.svelte | 98 +++++++- .../routes/(app)/members/InviteCard.svelte | 17 ++ .../routes/(app)/members/InviteCard.test.ts | 23 ++ .../routes/(app)/members/InvitePanel.svelte | 50 ++++ .../routes/(app)/members/InvitePanel.test.ts | 34 +++ .../routes/(app)/members/MemberCard.svelte | 215 ++++++++++++++++++ .../routes/(app)/members/MemberCard.test.ts | 147 ++++++++++++ .../routes/(app)/members/MemberGrid.svelte | 67 ++++++ .../routes/(app)/members/MemberGrid.test.ts | 73 ++++++ .../routes/(app)/members/RemoveDialog.svelte | 86 +++++++ .../routes/(app)/members/RemoveDialog.test.ts | 56 +++++ .../routes/(app)/members/[userId]/+server.ts | 21 ++ .../(app)/members/[userId]/server.test.ts | 50 ++++ .../routes/(app)/members/invites/+server.ts | 9 + .../routes/(app)/members/page.server.test.ts | 74 ++++++ 21 files changed, 1170 insertions(+), 1 deletion(-) rename backend/src/main/resources/db/migration/{V006__add_invite_invalidated_at.sql => V026__add_invite_invalidated_at.sql} (100%) create mode 100644 frontend/src/lib/components/SegmentedControl.svelte create mode 100644 frontend/src/lib/components/SegmentedControl.test.ts create mode 100644 frontend/src/lib/components/Toast.svelte create mode 100644 frontend/src/lib/components/Toast.test.ts create mode 100644 frontend/src/routes/(app)/members/+page.server.ts create mode 100644 frontend/src/routes/(app)/members/InviteCard.svelte create mode 100644 frontend/src/routes/(app)/members/InviteCard.test.ts create mode 100644 frontend/src/routes/(app)/members/InvitePanel.svelte create mode 100644 frontend/src/routes/(app)/members/InvitePanel.test.ts create mode 100644 frontend/src/routes/(app)/members/MemberCard.svelte create mode 100644 frontend/src/routes/(app)/members/MemberCard.test.ts create mode 100644 frontend/src/routes/(app)/members/MemberGrid.svelte create mode 100644 frontend/src/routes/(app)/members/MemberGrid.test.ts create mode 100644 frontend/src/routes/(app)/members/RemoveDialog.svelte create mode 100644 frontend/src/routes/(app)/members/RemoveDialog.test.ts create mode 100644 frontend/src/routes/(app)/members/[userId]/+server.ts create mode 100644 frontend/src/routes/(app)/members/[userId]/server.test.ts create mode 100644 frontend/src/routes/(app)/members/invites/+server.ts create mode 100644 frontend/src/routes/(app)/members/page.server.test.ts diff --git a/backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql b/backend/src/main/resources/db/migration/V026__add_invite_invalidated_at.sql similarity index 100% rename from backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql rename to backend/src/main/resources/db/migration/V026__add_invite_invalidated_at.sql diff --git a/frontend/src/lib/components/SegmentedControl.svelte b/frontend/src/lib/components/SegmentedControl.svelte new file mode 100644 index 0000000..a83104b --- /dev/null +++ b/frontend/src/lib/components/SegmentedControl.svelte @@ -0,0 +1,50 @@ + + +
+ {#each options as option (option.value)} + + {/each} +
+ + diff --git a/frontend/src/lib/components/SegmentedControl.test.ts b/frontend/src/lib/components/SegmentedControl.test.ts new file mode 100644 index 0000000..708e21c --- /dev/null +++ b/frontend/src/lib/components/SegmentedControl.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/lib/components/Toast.svelte b/frontend/src/lib/components/Toast.svelte new file mode 100644 index 0000000..47fb71a --- /dev/null +++ b/frontend/src/lib/components/Toast.svelte @@ -0,0 +1,31 @@ + + +{#if visible} +
+ {message} + +
+{/if} diff --git a/frontend/src/lib/components/Toast.test.ts b/frontend/src/lib/components/Toast.test.ts new file mode 100644 index 0000000..114b29b --- /dev/null +++ b/frontend/src/lib/components/Toast.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/routes/(app)/members/+page.server.ts b/frontend/src/routes/(app)/members/+page.server.ts new file mode 100644 index 0000000..6e63458 --- /dev/null +++ b/frontend/src/routes/(app)/members/+page.server.ts @@ -0,0 +1,17 @@ +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 + }; +}; diff --git a/frontend/src/routes/(app)/members/+page.svelte b/frontend/src/routes/(app)/members/+page.svelte index a4722af..5e54c8f 100644 --- a/frontend/src/routes/(app)/members/+page.svelte +++ b/frontend/src/routes/(app)/members/+page.svelte @@ -1 +1,97 @@ -

Mitglieder

+ + +Mitglieder — Mealprep + +
+

Mitglieder

+ + (showInvitePanel = !showInvitePanel)} + /> + + {#if showInvitePanel && isPlanner && activeInvite} + + {/if} + + (removeTarget = null)} + /> + + (toastVisible = false)} /> +
diff --git a/frontend/src/routes/(app)/members/InviteCard.svelte b/frontend/src/routes/(app)/members/InviteCard.svelte new file mode 100644 index 0000000..d5d0e55 --- /dev/null +++ b/frontend/src/routes/(app)/members/InviteCard.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/routes/(app)/members/InviteCard.test.ts b/frontend/src/routes/(app)/members/InviteCard.test.ts new file mode 100644 index 0000000..5974e5d --- /dev/null +++ b/frontend/src/routes/(app)/members/InviteCard.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/routes/(app)/members/InvitePanel.svelte b/frontend/src/routes/(app)/members/InvitePanel.svelte new file mode 100644 index 0000000..0eb9075 --- /dev/null +++ b/frontend/src/routes/(app)/members/InvitePanel.svelte @@ -0,0 +1,50 @@ + + +
+

+ {invite.shareUrl || invite.inviteCode} +

+ +
+ + + +
+ +

+ Läuft ab: {formatExpiry(invite.expiresAt)} +

+
diff --git a/frontend/src/routes/(app)/members/InvitePanel.test.ts b/frontend/src/routes/(app)/members/InvitePanel.test.ts new file mode 100644 index 0000000..77ba4f3 --- /dev/null +++ b/frontend/src/routes/(app)/members/InvitePanel.test.ts @@ -0,0 +1,34 @@ +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(); + }); +}); diff --git a/frontend/src/routes/(app)/members/MemberCard.svelte b/frontend/src/routes/(app)/members/MemberCard.svelte new file mode 100644 index 0000000..cabcd40 --- /dev/null +++ b/frontend/src/routes/(app)/members/MemberCard.svelte @@ -0,0 +1,215 @@ + + +
+ +
+ {initials} +
+ + +
+ {member.displayName} + {#if isCurrentUser} + Du + {/if} +
+ + + {#if editingRole} + { + onrolechange(member, newValue); + editingRole = false; + }} + /> + {:else} + + {member.role === 'planner' ? 'Planer' : 'Mitglied'} + + {/if} + + + {#if isPlanner && !isCurrentUser} + + + + {#if menuOpen} +
+ + +
+ {/if} + {/if} +
+ + diff --git a/frontend/src/routes/(app)/members/MemberCard.test.ts b/frontend/src/routes/(app)/members/MemberCard.test.ts new file mode 100644 index 0000000..a63bf41 --- /dev/null +++ b/frontend/src/routes/(app)/members/MemberCard.test.ts @@ -0,0 +1,147 @@ +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(); + }); +}); diff --git a/frontend/src/routes/(app)/members/MemberGrid.svelte b/frontend/src/routes/(app)/members/MemberGrid.svelte new file mode 100644 index 0000000..9e994ca --- /dev/null +++ b/frontend/src/routes/(app)/members/MemberGrid.svelte @@ -0,0 +1,67 @@ + + +
+ {#each sortedMembers as m (m.userId)} + onrolechange(m, role)} + /> + {/each} + {#if isPlanner && showInviteCard} + + {/if} +
+ + diff --git a/frontend/src/routes/(app)/members/MemberGrid.test.ts b/frontend/src/routes/(app)/members/MemberGrid.test.ts new file mode 100644 index 0000000..1745123 --- /dev/null +++ b/frontend/src/routes/(app)/members/MemberGrid.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/routes/(app)/members/RemoveDialog.svelte b/frontend/src/routes/(app)/members/RemoveDialog.svelte new file mode 100644 index 0000000..996db2f --- /dev/null +++ b/frontend/src/routes/(app)/members/RemoveDialog.svelte @@ -0,0 +1,86 @@ + + +{#if show} + {#if isMobile()} + +
+

Mitglied entfernen

+

+ Soll {member.displayName} wirklich entfernt werden? +

+
+ + +
+
+
+ {:else} +
+
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + > +

Mitglied entfernen

+

+ Soll {member.displayName} wirklich entfernt werden? +

+
+ + +
+
+
+ {/if} +{/if} diff --git a/frontend/src/routes/(app)/members/RemoveDialog.test.ts b/frontend/src/routes/(app)/members/RemoveDialog.test.ts new file mode 100644 index 0000000..9da8f9b --- /dev/null +++ b/frontend/src/routes/(app)/members/RemoveDialog.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/routes/(app)/members/[userId]/+server.ts b/frontend/src/routes/(app)/members/[userId]/+server.ts new file mode 100644 index 0000000..d53d711 --- /dev/null +++ b/frontend/src/routes/(app)/members/[userId]/+server.ts @@ -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 }); +}; diff --git a/frontend/src/routes/(app)/members/[userId]/server.test.ts b/frontend/src/routes/(app)/members/[userId]/server.test.ts new file mode 100644 index 0000000..6bc2b06 --- /dev/null +++ b/frontend/src/routes/(app)/members/[userId]/server.test.ts @@ -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); + }); +}); diff --git a/frontend/src/routes/(app)/members/invites/+server.ts b/frontend/src/routes/(app)/members/invites/+server.ts new file mode 100644 index 0000000..76d1f44 --- /dev/null +++ b/frontend/src/routes/(app)/members/invites/+server.ts @@ -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, { status: response?.status ?? 201 }); +}; diff --git a/frontend/src/routes/(app)/members/page.server.test.ts b/frontend/src/routes/(app)/members/page.server.test.ts new file mode 100644 index 0000000..55f2bc5 --- /dev/null +++ b/frontend/src/routes/(app)/members/page.server.test.ts @@ -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).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).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(); + }); +}); -- 2.49.1 From df3b774f0ca34a63ffd2b91aae18c3759d59cf15 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 19:45:32 +0200 Subject: [PATCH 04/10] fix(members): match settings page padding and h1 typography Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/(app)/members/+page.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/(app)/members/+page.svelte b/frontend/src/routes/(app)/members/+page.svelte index 5e54c8f..0f1591e 100644 --- a/frontend/src/routes/(app)/members/+page.svelte +++ b/frontend/src/routes/(app)/members/+page.svelte @@ -69,8 +69,8 @@ Mitglieder — Mealprep -
-

Mitglieder

+
+

Mitglieder

(toastVisible = false)} /> -
+ -- 2.49.1 From 4e67ff4258e2c336e2fa1411d221ad28fb8f76e9 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 19:54:26 +0200 Subject: [PATCH 05/10] =?UTF-8?q?feat(members):=20align=20grid=20UI=20to?= =?UTF-8?q?=20spec=20=E2=80=94=20avatar=20colors,=20badges,=20join=20date,?= =?UTF-8?q?=20invite=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemberCard: white bg, 1px border + shadow-card, centered column layout, avatar color by role (green-dark/blue), role badge with role-specific colors, join date "seit DD.MM.YYYY", Du-badge below join date, ⋯ kebab with icons and divider, inline role-control with Abbrechen, blue editing border #B5D4F4 - InviteCard: white bg, 1.5px dashed border, min-height 180px, plus circle, label "Mitglied einladen", full hover state (green border/bg/icon/label) - InvitePanel: white bg, title "Einladelink teilen", description, mono link box, yellow expiry pill when ≤ 24h, text-link "Neuen Link generieren" - RemoveDialog: white bg, padding 28px 32px, "?" in title, updated body text - +page.server.ts: expose householdName from locals.haushalt - +page.svelte: subtitle "{n} Mitglieder · {householdName}" - Tests: add join date format test, Abbrechen test, InvitePanel title test Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/(app)/members/+page.server.ts | 3 +- .../src/routes/(app)/members/+page.svelte | 3 +- .../routes/(app)/members/InviteCard.svelte | 73 +++- .../routes/(app)/members/InvitePanel.svelte | 123 +++++- .../routes/(app)/members/InvitePanel.test.ts | 5 + .../routes/(app)/members/MemberCard.svelte | 383 +++++++++++++----- .../routes/(app)/members/MemberCard.test.ts | 28 ++ .../routes/(app)/members/RemoveDialog.svelte | 30 +- 8 files changed, 505 insertions(+), 143 deletions(-) diff --git a/frontend/src/routes/(app)/members/+page.server.ts b/frontend/src/routes/(app)/members/+page.server.ts index 6e63458..7324498 100644 --- a/frontend/src/routes/(app)/members/+page.server.ts +++ b/frontend/src/routes/(app)/members/+page.server.ts @@ -12,6 +12,7 @@ export const load: PageServerLoad = async ({ fetch, locals }) => { return { members: membersRes.data ?? [], currentUserId: locals.benutzer!.id, - activeInvite: inviteRes.data?.data ?? null + activeInvite: inviteRes.data?.data ?? null, + householdName: locals.haushalt?.name ?? '' }; }; diff --git a/frontend/src/routes/(app)/members/+page.svelte b/frontend/src/routes/(app)/members/+page.svelte index 0f1591e..5c2dd64 100644 --- a/frontend/src/routes/(app)/members/+page.svelte +++ b/frontend/src/routes/(app)/members/+page.svelte @@ -70,7 +70,8 @@ Mitglieder — Mealprep
-

Mitglieder

+

Mitglieder

+

{members.length} Mitglieder{data.householdName ? ` · ${data.householdName}` : ''}

- - Einladen +
+
+
Mitglied einladen
+ + diff --git a/frontend/src/routes/(app)/members/InvitePanel.svelte b/frontend/src/routes/(app)/members/InvitePanel.svelte index 0eb9075..e7bd100 100644 --- a/frontend/src/routes/(app)/members/InvitePanel.svelte +++ b/frontend/src/routes/(app)/members/InvitePanel.svelte @@ -23,28 +23,117 @@ 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 + ); -
-

- {invite.shareUrl || invite.inviteCode} -

+
+
Einladelink teilen
+
Wer diesen Link öffnet, kann dem Haushalt als Mitglied beitreten.
-
- - -
-

- Läuft ab: {formatExpiry(invite.expiresAt)} -

+
+ Läuft ab: {formatExpiry(invite.expiresAt)} +
+ +
+ + diff --git a/frontend/src/routes/(app)/members/InvitePanel.test.ts b/frontend/src/routes/(app)/members/InvitePanel.test.ts index 77ba4f3..5212215 100644 --- a/frontend/src/routes/(app)/members/InvitePanel.test.ts +++ b/frontend/src/routes/(app)/members/InvitePanel.test.ts @@ -31,4 +31,9 @@ describe('InvitePanel', () => { 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(); + }); }); diff --git a/frontend/src/routes/(app)/members/MemberCard.svelte b/frontend/src/routes/(app)/members/MemberCard.svelte index cabcd40..7e2cfa3 100644 --- a/frontend/src/routes/(app)/members/MemberCard.svelte +++ b/frontend/src/routes/(app)/members/MemberCard.svelte @@ -1,6 +1,5 @@ -- 2.49.1