feat(lists): add direct user invitation to shared lists and streamline live-sync indicator
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 56s

This commit is contained in:
sandrodach committed 2026-10-25 11:05:03 +01:00
1 parent 6e09888a44
commit 7bc5fbe001
7 files changed
+580 -29

No files matched your search

+54
View File
@@ -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)
+101
View File
@@ -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())
}
}
+94 -6
View File
@@ -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
+22
View File
@@ -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 {