From 7bc5fbe001968162625eae42033556007d99f704 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 11:05:03 +0100 Subject: [PATCH] feat(lists): add direct user invitation to shared lists and streamline live-sync indicator --- frontend/src/App.tsx | 4 +- .../src/features/lists/SharedListsView.tsx | 314 ++++++++++++++++-- frontend/src/lib/api.ts | 14 + internal/api/server.go | 54 +++ internal/api/server_test.go | 101 ++++++ internal/storage/sqlite.go | 100 +++++- internal/storage/sqlite_test.go | 22 ++ 7 files changed, 580 insertions(+), 29 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 212b349..5f09e67 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -217,7 +217,7 @@ export const App: React.FC = () => { if (!msg) return; if ( typeof msg.event === 'string' && - (msg.event.startsWith('TASK_') || msg.event.startsWith('DOC_')) + (msg.event.startsWith('TASK_') || msg.event.startsWith('DOC_') || msg.event.startsWith('SPACE_')) ) { api.getMe().then((res) => { setStats(res.stats); @@ -593,7 +593,7 @@ export const App: React.FC = () => { )} {activeTab === 'lists' && ( - + )} {activeTab === 'user_settings' && ( diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 54c0203..abd3a56 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -10,17 +10,19 @@ import { Radio, Sparkles, UserPlus, + X, } from 'lucide-react'; -import { ListItem, SharedList, Space, WSMessage } from '../../lib/types'; +import { ListItem, SharedList, Space, User, WSMessage } from '../../lib/types'; import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api'; interface SharedListsViewProps { onRefreshGlobal: () => void; + currentUser?: User | null; } const CATEGORIES = ['Obst & Gemüse', 'Kühlregal', 'Vorrat & Konserven', 'Backwaren', 'Getränke', 'Drogerie', 'Sonstiges']; -export const SharedListsView: React.FC = ({ onRefreshGlobal }) => { +export const SharedListsView: React.FC = ({ onRefreshGlobal, currentUser }) => { const [spaces, setSpaces] = useState([]); const [activeSpace, setActiveSpace] = useState(null); const [lists, setLists] = useState([]); @@ -31,7 +33,6 @@ export const SharedListsView: React.FC = ({ onRefreshGloba const [newItemCategory, setNewItemCategory] = useState(''); const [wsConnected, setWsConnected] = useState(false); - const [onlineUsers, setOnlineUsers] = useState(1); const [copiedInvite, setCopiedInvite] = useState(false); const [showNewListModal, setShowNewListModal] = useState(false); @@ -43,13 +44,25 @@ export const SharedListsView: React.FC = ({ onRefreshGloba const [showNewSpaceModal, setShowNewSpaceModal] = useState(false); const [newSpaceName, setNewSpaceName] = useState(''); + const [showMembersModal, setShowMembersModal] = useState(false); + const [collaborators, setCollaborators] = useState([]); + const [inviteTarget, setInviteTarget] = useState(''); + const [inviteRole, setInviteRole] = useState<'editor' | 'viewer'>('editor'); + const [invitingLoading, setInvitingLoading] = useState(false); + const [inviteError, setInviteError] = useState(null); + const wsRef = useRef(null); const loadSpaces = async () => { const sp = await api.getSpaces(); setSpaces(sp); - if (sp.length > 0 && !activeSpace) { - setActiveSpace(sp[0]); + if (sp.length > 0) { + if (!activeSpace) { + setActiveSpace(sp[0]); + } else { + const found = sp.find((s) => s.id === activeSpace.id); + if (found) setActiveSpace(found); + } } }; @@ -90,9 +103,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba ws.onmessage = (evt) => { try { const msg: WSMessage = JSON.parse(evt.data); - if (msg.event === 'USER_PRESENCE' && msg.payload?.onlineCount) { - setOnlineUsers(msg.payload.onlineCount); - } else if (msg.event === 'ITEM_CREATED' && msg.list_id) { + if (msg.event === 'ITEM_CREATED' && msg.list_id) { const newItem: ListItem = msg.payload; setLists((prev) => prev.map((l) => { @@ -125,6 +136,8 @@ export const SharedListsView: React.FC = ({ onRefreshGloba setLists((prev) => prev.map((l) => (l.id === msg.list_id ? updatedList : l))); } else if (msg.event === 'LIST_CREATED' || msg.event === 'LIST_DELETED') { loadListsForSpace(activeSpace.id); + } else if (msg.event === 'MEMBER_ADDED' || msg.event === 'MEMBER_REMOVED') { + loadSpaces(); } } catch { // ignore malformed frames @@ -139,6 +152,23 @@ export const SharedListsView: React.FC = ({ onRefreshGloba }; }, [activeSpace?.id]); + useEffect(() => { + const handleGlobalWs = (e: any) => { + const msg = e.detail; + if (!msg) return; + if ( + msg.event === 'SPACE_JOINED' || + msg.event === 'SPACE_LEFT' || + msg.event === 'MEMBER_ADDED' || + msg.event === 'MEMBER_REMOVED' + ) { + loadSpaces(); + } + }; + window.addEventListener('mindsift:ws', handleGlobalWs); + return () => window.removeEventListener('mindsift:ws', handleGlobalWs); + }, []); + const activeList = lists.find((l) => l.id === activeListId) || lists[0] || null; const handleAddItem = async (e: React.FormEvent) => { @@ -245,6 +275,80 @@ export const SharedListsView: React.FC = ({ onRefreshGloba setTimeout(() => setCopiedInvite(false), 2000); }; + const handleAddMember = async (e: React.FormEvent) => { + e.preventDefault(); + if (!activeSpace) return; + const target = inviteTarget.trim(); + if (!target) return; + setInvitingLoading(true); + setInviteError(null); + try { + const isId = collaborators.some((u) => u.id === target); + const newMember = await api.addSpaceMember(activeSpace.id, { + userId: isId ? target : undefined, + email: !isId ? target : undefined, + role: inviteRole, + }); + setActiveSpace((prev) => + prev + ? { + ...prev, + members: [...(prev.members || []).filter((m) => m.userId !== newMember.userId), newMember], + } + : prev + ); + setSpaces((prev) => + prev.map((s) => + s.id === activeSpace.id + ? { + ...s, + members: [...(s.members || []).filter((m) => m.userId !== newMember.userId), newMember], + } + : s + ) + ); + setInviteTarget(''); + onRefreshGlobal(); + } catch (err: any) { + setInviteError(err.message || 'Mitglied konnte nicht eingeladen werden'); + } finally { + setInvitingLoading(false); + } + }; + + const handleRemoveMember = async (targetUserId: string) => { + if (!activeSpace) return; + if (!window.confirm('Dieses Mitglied wirklich aus der Einkaufsliste / dem Bereich entfernen?')) return; + setInvitingLoading(true); + setInviteError(null); + try { + await api.removeSpaceMember(activeSpace.id, targetUserId); + setActiveSpace((prev) => + prev + ? { + ...prev, + members: (prev.members || []).filter((m) => m.userId !== targetUserId), + } + : prev + ); + setSpaces((prev) => + prev.map((s) => + s.id === activeSpace.id + ? { + ...s, + members: (s.members || []).filter((m) => m.userId !== targetUserId), + } + : s + ) + ); + onRefreshGlobal(); + } catch (err: any) { + setInviteError(err.message || 'Mitglied konnte nicht entfernt werden'); + } finally { + setInvitingLoading(false); + } + }; + const openItems = activeList?.items.filter((i) => !i.isCompleted) || []; const completedItems = activeList?.items.filter((i) => i.isCompleted) || []; @@ -283,31 +387,34 @@ export const SharedListsView: React.FC = ({ onRefreshGloba - {/* Realtime Status & Invite Code Pill */} + {/* Realtime Status & Invite User Button */} {activeSpace && (
- - {wsConnected ? `Echtzeit-Sync (${onlineUsers} aktiv)` : 'Offline-Puffer'} +
@@ -571,6 +678,171 @@ export const SharedListsView: React.FC = ({ onRefreshGloba )} ) : null} + + {/* Space & Shopping List Members Modal */} + {showMembersModal && activeSpace && ( +
+
+ {/* Modal Header */} +
+
+ +

+ Einkaufsliste teilen: {activeSpace.name} +

+
+ +
+ + {inviteError && ( +
+ {inviteError} +
+ )} + + {/* Invite User Form */} +
+
User direkt einladen
+
+ + +
+ + {/* Direct Username / Email input if user not selected from list */} + {(!inviteTarget || !collaborators.some((u) => u.id === inviteTarget)) && ( + setInviteTarget(e.target.value)} + className="input-base text-xs w-full" + /> + )} + +
+ +
+
+ + {/* Current Space Members List */} +
+
+ Mitglieder in dieser Liste ({activeSpace.members?.length || 1}) +
+
+ {(activeSpace.members || []).map((m) => { + const isOwner = m.role === 'owner' || m.userId === activeSpace.ownerId; + const isMe = m.userId === currentUser?.id; + const canRemove = + (activeSpace.ownerId === currentUser?.id || currentUser?.role === 'admin') && + !isOwner; + + return ( +
+
+
+ {(m.displayName || m.email || '?')[0].toUpperCase()} +
+
+
+ {m.displayName || m.email} {isMe && (Du)} +
+
{m.email}
+
+
+ +
+ + {isOwner ? 'Eigentümer' : m.role === 'editor' ? 'Bearbeiter' : 'Leser'} + + {canRemove && ( + + )} +
+
+ ); + })} +
+
+ + {/* Invite Code Alternative */} +
+ Alternativ per Code beitreten: + +
+
+
+ )} ); }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1f62480..fdd36cf 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -11,6 +11,7 @@ import { PriorityLevel, SharedList, Space, + SpaceMember, SystemSettings, Team, TaskList, @@ -517,6 +518,19 @@ export const api = { }); }, + async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; role?: string }): Promise { + return request(`/api/v1/spaces/${spaceId}/members`, { + method: 'POST', + body: JSON.stringify(target), + }); + }, + + async removeSpaceMember(spaceId: string, userId: string): Promise { + await request(`/api/v1/spaces/${spaceId}/members/${userId}`, { + method: 'DELETE', + }); + }, + async getSpaceLists(spaceId: string): Promise { const res = await request(`/api/v1/spaces/${spaceId}/lists`); return ensureArray(res).map((l) => ({ diff --git a/internal/api/server.go b/internal/api/server.go index 29b7018..58451fa 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -117,6 +117,8 @@ func (s *Server) Router() http.Handler { r.Get("/api/v1/spaces", s.handleGetSpaces) r.Post("/api/v1/spaces", s.handleCreateSpace) r.Post("/api/v1/spaces/join", s.handleJoinSpace) + r.Post("/api/v1/spaces/{id}/members", s.handleAddSpaceMember) + r.Delete("/api/v1/spaces/{id}/members/{userId}", s.handleRemoveSpaceMember) r.Get("/api/v1/spaces/{id}/lists", s.handleGetSpaceLists) r.Post("/api/v1/spaces/{id}/lists", s.handleCreateList) @@ -981,9 +983,61 @@ func (s *Server) handleJoinSpace(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } + s.wsHub.BroadcastToUser(userID, "SPACE_JOINED", map[string]interface{}{"spaceId": sp.ID}) + s.wsHub.Broadcast(sp.ID, "", "MEMBER_ADDED", map[string]interface{}{"spaceId": sp.ID, "userId": userID}) + writeJSON(w, http.StatusOK, sp) } +func (s *Server) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) { + currentUserID := auth.GetUserID(r.Context()) + spaceID := chi.URLParam(r, "id") + var req struct { + UserID string `json:"userId"` + Email string `json:"email"` + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "Ungültiges JSON") + return + } + target := req.UserID + if target == "" { + target = req.Email + } + if target == "" { + writeError(w, http.StatusBadRequest, "Benutzer-ID, Benutzername oder E-Mail erforderlich") + return + } + + member, err := s.store.AddSpaceMember(currentUserID, spaceID, target, req.Role) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + s.wsHub.BroadcastToUser(member.UserID, "SPACE_JOINED", map[string]interface{}{"spaceId": spaceID}) + s.wsHub.Broadcast(spaceID, "", "MEMBER_ADDED", member) + + writeJSON(w, http.StatusCreated, member) +} + +func (s *Server) handleRemoveSpaceMember(w http.ResponseWriter, r *http.Request) { + currentUserID := auth.GetUserID(r.Context()) + spaceID := chi.URLParam(r, "id") + targetUserID := chi.URLParam(r, "userId") + + if err := s.store.RemoveSpaceMember(currentUserID, spaceID, targetUserID); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + s.wsHub.BroadcastToUser(targetUserID, "SPACE_LEFT", map[string]interface{}{"spaceId": spaceID}) + s.wsHub.Broadcast(spaceID, "", "MEMBER_REMOVED", map[string]interface{}{"spaceId": spaceID, "userId": targetUserID}) + + writeJSON(w, http.StatusOK, map[string]bool{"success": true}) +} + func (s *Server) handleGetSpaceLists(w http.ResponseWriter, r *http.Request) { spaceID := chi.URLParam(r, "id") lists, err := s.store.GetListsForSpace(spaceID) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 5df8d6c..568ca63 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -518,3 +518,104 @@ func TestLoginWithUsernameAndDemoMode(t *testing.T) { t.Fatalf("expected 401 Unauthorized for wrong password, got %d", rec.Code) } } + +func TestSpaceMembersDirectInvitationAPI(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "space_members_test.db") + store, err := storage.NewStore(dbPath) + if err != nil { + t.Fatalf("failed to create store: %v", err) + } + defer store.Close() + + if err := store.EnsureDemoAccounts(); err != nil { + t.Fatalf("failed to seed demo accounts: %v", err) + } + + cfg := &config.Config{ + Port: "8080", + DatabasePath: dbPath, + JWTSecret: "test-secret", + } + srv := NewServer(cfg, store, ws.NewHub(), nil) + router := srv.Router() + + adminUser, err := store.GetUserByLogin("admin") + if err != nil { + t.Fatalf("failed to get admin user: %v", err) + } + normalUser, err := store.GetUserByLogin("user") + if err != nil { + t.Fatalf("failed to get normal user: %v", err) + } + + adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName) + if err != nil { + t.Fatalf("failed to generate token: %v", err) + } + userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName) + if err != nil { + t.Fatalf("failed to generate token: %v", err) + } + + // 1. Admin creates a space + createSpaceBody := []byte(`{"name":"Einkauf WG","icon":"🛒","description":"Gemeinsame Einkaufsliste"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/spaces", bytes.NewReader(createSpaceBody)) + req.Header.Set("Authorization", "Bearer "+adminToken) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 Created from /api/v1/spaces, got %d: %s", rec.Code, rec.Body.String()) + } + var createdSpace domain.Space + _ = json.Unmarshal(rec.Body.Bytes(), &createdSpace) + if createdSpace.ID == "" { + t.Fatalf("expected created space with ID, got %+v", createdSpace) + } + + // 2. Admin invites normalUser by username "user" + addMemberBody := []byte(`{"userId":"user","role":"editor"}`) + req = httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+createdSpace.ID+"/members", bytes.NewReader(addMemberBody)) + req.Header.Set("Authorization", "Bearer "+adminToken) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 Created from add space member, got %d: %s", rec.Code, rec.Body.String()) + } + var newMember domain.SpaceMember + _ = json.Unmarshal(rec.Body.Bytes(), &newMember) + if newMember.UserID != normalUser.ID { + t.Fatalf("expected member userId %s, got %s", normalUser.ID, newMember.UserID) + } + + // 3. User checks their spaces; the shared space must be listed + req = httptest.NewRequest(http.MethodGet, "/api/v1/spaces", nil) + req.Header.Set("Authorization", "Bearer "+userToken) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 OK for user spaces, got %d", rec.Code) + } + var userSpaces []domain.Space + _ = json.Unmarshal(rec.Body.Bytes(), &userSpaces) + found := false + for _, s := range userSpaces { + if s.ID == createdSpace.ID { + found = true + break + } + } + if !found { + t.Fatalf("expected space %s to be visible to user, got %+v", createdSpace.ID, userSpaces) + } + + // 4. Admin removes user from space + req = httptest.NewRequest(http.MethodDelete, "/api/v1/spaces/"+createdSpace.ID+"/members/"+normalUser.ID, nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 OK for remove member, got %d: %s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index dba3530..26ba589 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -606,14 +606,25 @@ func (s *Store) GetDiscoverableUsers(viewerID string) ([]domain.User, error) { return []domain.User{*viewer}, nil } - // teams_only: users sharing at least one team or organization with viewer + // teams_only: if no teams exist yet, allow discovering users on this instance + var teamCount int + _ = s.db.QueryRow(`SELECT COUNT(*) FROM teams`).Scan(&teamCount) + if teamCount == 0 { + return s.GetAllUsers() + } + + // users sharing at least one team, organization or space with viewer rows, err := s.db.Query( `SELECT DISTINCT `+userSelectCols+` FROM users WHERE id = ? OR id IN ( SELECT tm2.user_id FROM team_members tm1 JOIN team_members tm2 ON tm1.team_id = tm2.team_id WHERE tm1.user_id = ? + UNION + SELECT sm2.user_id FROM space_members sm1 + JOIN space_members sm2 ON sm1.space_id = sm2.space_id + WHERE sm1.user_id = ? )`, - viewerID, viewerID, + viewerID, viewerID, viewerID, ) if err != nil { return []domain.User{*viewer}, nil @@ -1460,12 +1471,16 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi permission = "read" } - // Resolve target user by ID or Email + // Resolve target user by ID, username, or Email var targetUser *domain.User cleanTarget := strings.TrimSpace(targetUserIdentifier) - if strings.Contains(cleanTarget, "@") { - targetUser, err = s.GetUserByEmail(cleanTarget) - } else { + if strings.HasPrefix(cleanTarget, "usr_") { + targetUser, _ = s.GetUserByID(cleanTarget) + } + if targetUser == nil { + targetUser, _ = s.GetUserByLogin(cleanTarget) + } + if targetUser == nil { targetUser, err = s.GetUserByID(cleanTarget) } if err != nil || targetUser == nil { @@ -1636,6 +1651,79 @@ func (s *Store) GetSpaceMembers(spaceID string) ([]domain.SpaceMember, error) { return members, nil } +func (s *Store) AddSpaceMember(currentUserID, spaceID, targetUserIdentifier, role string) (*domain.SpaceMember, error) { + // 1. Check if current user is member of space + var myRole string + err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole) + if err != nil { + return nil, errors.New("keine Berechtigung für diesen Bereich") + } + + // 2. Resolve target user by ID, username or email + cleanTarget := strings.TrimSpace(targetUserIdentifier) + var targetUser *domain.User + if strings.HasPrefix(cleanTarget, "usr_") { + targetUser, _ = s.GetUserByID(cleanTarget) + } + if targetUser == nil { + targetUser, _ = s.GetUserByLogin(cleanTarget) + } + if targetUser == nil { + targetUser, err = s.GetUserByID(cleanTarget) + } + if err != nil || targetUser == nil { + return nil, errors.New("benutzer nicht gefunden") + } + + if role != "owner" && role != "editor" && role != "viewer" { + role = "editor" + } + + now := domain.NowISO() + _, err = s.db.Exec( + `INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, ?, ?) + ON CONFLICT(space_id, user_id) DO UPDATE SET role = excluded.role`, + spaceID, targetUser.ID, role, now, + ) + if err != nil { + return nil, err + } + + return &domain.SpaceMember{ + SpaceID: spaceID, + UserID: targetUser.ID, + DisplayName: targetUser.DisplayName, + Email: targetUser.Email, + Role: role, + JoinedAt: now, + }, nil +} + +func (s *Store) RemoveSpaceMember(currentUserID, spaceID, targetUserID string) error { + var myRole string + err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole) + if err != nil { + return errors.New("keine Berechtigung für diesen Bereich") + } + + if currentUserID != targetUserID && myRole != "owner" { + return errors.New("nur der Eigentümer kann andere Mitglieder entfernen") + } + + var targetRole string + _ = s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, targetUserID).Scan(&targetRole) + if targetRole == "owner" { + var ownerCount int + _ = s.db.QueryRow(`SELECT COUNT(*) FROM space_members WHERE space_id = ? AND role = 'owner'`, spaceID).Scan(&ownerCount) + if ownerCount <= 1 { + return errors.New("der letzte Eigentümer kann den Bereich nicht verlassen") + } + } + + _, err = s.db.Exec(`DELETE FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, targetUserID) + return err +} + func (s *Store) GetSpacesForUser(userID string) ([]domain.Space, error) { rows, err := s.db.Query( `SELECT s.id, s.name, s.icon, s.description, s.invite_code, s.is_personal, s.owner_id, s.created_at diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index b46259d..49c2efa 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -65,6 +65,28 @@ func TestStoreLifecycle(t *testing.T) { t.Fatalf("ToggleListItem failed: %v", err) } + // Test AddSpaceMember & RemoveSpaceMember + u2, err := store.CreateUser("collab_space@example.com", "hash", "Collab User", "member") + if err != nil { + t.Fatalf("CreateUser u2 failed: %v", err) + } + member, err := store.AddSpaceMember(user.ID, sp.ID, u2.ID, "editor") + if err != nil || member.UserID != u2.ID { + t.Fatalf("AddSpaceMember failed: %v", err) + } + members, err := store.GetSpaceMembers(sp.ID) + if err != nil || len(members) != 2 { + t.Fatalf("expected 2 members, got %d", len(members)) + } + err = store.RemoveSpaceMember(user.ID, sp.ID, u2.ID) + if err != nil { + t.Fatalf("RemoveSpaceMember failed: %v", err) + } + membersAfter, _ := store.GetSpaceMembers(sp.ID) + if len(membersAfter) != 1 { + t.Fatalf("expected 1 member after remove, got %d", len(membersAfter)) + } + // 4. Test Webhook API Key generation & validation apiKey, err := store.CreateAPIKey(user.ID, "Home Assistant") if err != nil {