feat(members): implement /members page — Kachel-Ansicht (E2, issue #48) #58
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -30,3 +30,6 @@ spring:
|
|||||||
|
|
||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
|
|
||||||
|
app:
|
||||||
|
base-url: http://localhost:5173
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
119
frontend/src/lib/api/schema.d.ts
vendored
119
frontend/src/lib/api/schema.d.ts
vendored
@@ -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?: {
|
||||||
|
|||||||
50
frontend/src/lib/components/SegmentedControl.svelte
Normal file
50
frontend/src/lib/components/SegmentedControl.svelte
Normal 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>
|
||||||
30
frontend/src/lib/components/SegmentedControl.test.ts
Normal file
30
frontend/src/lib/components/SegmentedControl.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
31
frontend/src/lib/components/Toast.svelte
Normal file
31
frontend/src/lib/components/Toast.svelte
Normal 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}
|
||||||
23
frontend/src/lib/components/Toast.test.ts
Normal file
23
frontend/src/lib/components/Toast.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
18
frontend/src/routes/(app)/members/+page.server.ts
Normal file
18
frontend/src/routes/(app)/members/+page.server.ts
Normal 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 ?? ''
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
84
frontend/src/routes/(app)/members/InviteCard.svelte
Normal file
84
frontend/src/routes/(app)/members/InviteCard.svelte
Normal 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>
|
||||||
23
frontend/src/routes/(app)/members/InviteCard.test.ts
Normal file
23
frontend/src/routes/(app)/members/InviteCard.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
139
frontend/src/routes/(app)/members/InvitePanel.svelte
Normal file
139
frontend/src/routes/(app)/members/InvitePanel.svelte
Normal 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>
|
||||||
39
frontend/src/routes/(app)/members/InvitePanel.test.ts
Normal file
39
frontend/src/routes/(app)/members/InvitePanel.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
386
frontend/src/routes/(app)/members/MemberCard.svelte
Normal file
386
frontend/src/routes/(app)/members/MemberCard.svelte
Normal 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>
|
||||||
175
frontend/src/routes/(app)/members/MemberCard.test.ts
Normal file
175
frontend/src/routes/(app)/members/MemberCard.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
67
frontend/src/routes/(app)/members/MemberGrid.svelte
Normal file
67
frontend/src/routes/(app)/members/MemberGrid.svelte
Normal 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>
|
||||||
73
frontend/src/routes/(app)/members/MemberGrid.test.ts
Normal file
73
frontend/src/routes/(app)/members/MemberGrid.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
86
frontend/src/routes/(app)/members/RemoveDialog.svelte
Normal file
86
frontend/src/routes/(app)/members/RemoveDialog.svelte
Normal 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}
|
||||||
56
frontend/src/routes/(app)/members/RemoveDialog.test.ts
Normal file
56
frontend/src/routes/(app)/members/RemoveDialog.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
21
frontend/src/routes/(app)/members/[userId]/+server.ts
Normal file
21
frontend/src/routes/(app)/members/[userId]/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
50
frontend/src/routes/(app)/members/[userId]/server.test.ts
Normal file
50
frontend/src/routes/(app)/members/[userId]/server.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
9
frontend/src/routes/(app)/members/invites/+server.ts
Normal file
9
frontend/src/routes/(app)/members/invites/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
33
frontend/src/routes/(app)/members/invites/server.test.ts
Normal file
33
frontend/src/routes/(app)/members/invites/server.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
74
frontend/src/routes/(app)/members/page.server.test.ts
Normal file
74
frontend/src/routes/(app)/members/page.server.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user