refactor: remove legacy spaces and migrate lists to standalone resources with direct sharing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
This commit is contained in:
1 parent
177b69234c
commit
0d6c603e9c
9 files changed
+616
-950
No files matched your search
+122
-185
@@ -63,6 +63,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest)
|
||||
r.Get("/api/v1/ws", s.handleWebSocket)
|
||||
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
|
||||
r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket)
|
||||
|
||||
// Authenticated API Routes
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -125,18 +126,14 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post("/api/v1/documents/{id}/lock/heartbeat", s.handleHeartbeatDocLock)
|
||||
r.Delete("/api/v1/documents/{id}/lock", s.handleUnlockDoc)
|
||||
|
||||
// Shared Spaces & Realtime Lists
|
||||
r.Get("/api/v1/spaces", s.handleGetSpaces)
|
||||
r.Post("/api/v1/spaces", s.handleCreateSpace)
|
||||
r.Post("/api/v1/spaces/join", s.handleJoinSpace)
|
||||
r.Get("/api/v1/spaces/{id}/shares", s.handleGetSpaceShares)
|
||||
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)
|
||||
|
||||
// Shared Lists & Realtime Collaboration
|
||||
r.Get("/api/v1/lists", s.handleGetAllLists)
|
||||
r.Post("/api/v1/lists", s.handleCreateList)
|
||||
r.Get("/api/v1/lists/{id}", s.handleGetListByID)
|
||||
r.Delete("/api/v1/lists/{id}", s.handleDeleteList)
|
||||
r.Get("/api/v1/lists/{id}/shares", s.handleGetListShares)
|
||||
r.Post("/api/v1/lists/{id}/shares", s.handleCreateListShare)
|
||||
r.Delete("/api/v1/lists/{id}/shares/{shareId}", s.handleDeleteListShare)
|
||||
r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem)
|
||||
r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem)
|
||||
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem)
|
||||
@@ -584,8 +581,8 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
|
||||
if converted, convErr := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", target, targetListID, dueDate, taskListID); convErr == nil && converted != nil {
|
||||
thought = converted
|
||||
if target == "list_item" && targetListID != "" {
|
||||
if lst, lErr := s.store.GetListByID(targetListID); lErr == nil && lst != nil {
|
||||
s.wsHub.Broadcast(lst.SpaceID, lst.ID, "ITEM_CREATED", lst)
|
||||
if lst, lErr := s.store.GetListByID(userID, targetListID); lErr == nil && lst != nil {
|
||||
s.wsHub.BroadcastListEvent(lst.ID, "ITEM_CREATED", lst)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -661,8 +658,8 @@ func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// If converted to a list item, broadcast via WebSocket to the space
|
||||
if convertTo == "list_item" && targetListID != "" {
|
||||
if lst, err := s.store.GetListByID(targetListID); err == nil && lst != nil {
|
||||
s.wsHub.Broadcast(lst.SpaceID, lst.ID, "ITEM_CREATED", lst)
|
||||
if lst, err := s.store.GetListByID(userID, targetListID); err == nil && lst != nil {
|
||||
s.wsHub.BroadcastListEvent(lst.ID, "ITEM_CREATED", lst)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1073,151 +1070,7 @@ func (s *Server) handleUnlockDoc(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"unlocked": released})
|
||||
}
|
||||
|
||||
// --- SPACES & REALTIME LISTS ---
|
||||
|
||||
func (s *Server) handleGetSpaces(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
spaces, err := s.store.GetSpacesForUser(userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, spaces)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateSpace(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
sp, err := s.store.CreateSpace(userID, req.Name, req.Icon, req.Description, false)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, sp)
|
||||
}
|
||||
|
||||
func (s *Server) handleJoinSpace(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
var req struct {
|
||||
InviteCode string `json:"inviteCode"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
sp, err := s.store.JoinSpaceByInviteCode(userID, req.InviteCode)
|
||||
if err != nil {
|
||||
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"`
|
||||
TeamID string `json:"teamId"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Role != "owner" && req.Role != "editor" && req.Role != "viewer" {
|
||||
req.Role = "editor"
|
||||
}
|
||||
|
||||
if req.TeamID != "" {
|
||||
share, err := s.store.CreateSpaceTeamShare(currentUserID, spaceID, req.TeamID, req.Role)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
members, _ := s.store.GetTeamMembers(req.TeamID)
|
||||
for _, tm := range members {
|
||||
s.wsHub.BroadcastToUser(tm.UserID, "SPACE_JOINED", map[string]interface{}{"spaceId": spaceID})
|
||||
}
|
||||
s.wsHub.Broadcast(spaceID, "", "SPACE_SHARED", share)
|
||||
writeJSON(w, http.StatusCreated, share)
|
||||
return
|
||||
}
|
||||
|
||||
target := req.UserID
|
||||
if target == "" {
|
||||
target = req.Email
|
||||
}
|
||||
if target == "" {
|
||||
writeError(w, http.StatusBadRequest, "Benutzer-ID, Benutzername, E-Mail oder Team 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) handleGetSpaceShares(w http.ResponseWriter, r *http.Request) {
|
||||
spaceID := chi.URLParam(r, "id")
|
||||
shares, err := s.store.GetSpaceShares(spaceID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, shares)
|
||||
}
|
||||
|
||||
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 prefixed with sm_, strip it for individual space member removal
|
||||
cleanTargetID := strings.TrimPrefix(targetUserID, "sm_")
|
||||
|
||||
if err := s.store.RemoveSpaceMember(currentUserID, spaceID, cleanTargetID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.wsHub.BroadcastToUser(cleanTargetID, "SPACE_LEFT", map[string]interface{}{"spaceId": spaceID})
|
||||
s.wsHub.Broadcast(spaceID, "", "MEMBER_REMOVED", map[string]interface{}{"spaceId": spaceID, "userId": cleanTargetID})
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, lists)
|
||||
}
|
||||
// --- REALTIME LISTS & SHARING ---
|
||||
|
||||
func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
@@ -1229,8 +1082,19 @@ func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, lists)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetListByID(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
listID := chi.URLParam(r, "id")
|
||||
lst, err := s.store.GetListByID(userID, listID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "Liste nicht gefunden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, lst)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
|
||||
spaceID := chi.URLParam(r, "id")
|
||||
userID := auth.GetUserID(r.Context())
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
@@ -1241,28 +1105,96 @@ func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
lst, err := s.store.CreateList(spaceID, req.Title, req.Icon, req.ListType)
|
||||
lst, err := s.store.CreateList(userID, req.Title, req.Icon, req.ListType)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.Broadcast(spaceID, lst.ID, "LIST_CREATED", lst)
|
||||
s.wsHub.BroadcastListEvent(lst.ID, "LIST_CREATED", lst)
|
||||
writeJSON(w, http.StatusCreated, lst)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteList(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
listID := chi.URLParam(r, "id")
|
||||
lst, _ := s.store.GetListByID(listID)
|
||||
if err := s.store.DeleteList(listID); err != nil {
|
||||
if err := s.store.DeleteList(userID, listID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if lst != nil {
|
||||
s.wsHub.Broadcast(lst.SpaceID, listID, "LIST_DELETED", map[string]string{"listId": listID})
|
||||
}
|
||||
s.wsHub.BroadcastListEvent(listID, "LIST_DELETED", map[string]string{"listId": listID})
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetListShares(w http.ResponseWriter, r *http.Request) {
|
||||
listID := chi.URLParam(r, "id")
|
||||
shares, err := s.store.GetListShares(listID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, shares)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateListShare(w http.ResponseWriter, r *http.Request) {
|
||||
currentUserID := auth.GetUserID(r.Context())
|
||||
listID := chi.URLParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
TargetType string `json:"targetType"` // "user" or "team"
|
||||
TargetID string `json:"targetId"` // userId/email or teamId
|
||||
Role string `json:"role"` // "editor" or "viewer"
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
targetType := strings.ToLower(strings.TrimSpace(req.TargetType))
|
||||
if targetType != "team" {
|
||||
targetType = "user"
|
||||
}
|
||||
|
||||
role := strings.ToLower(strings.TrimSpace(req.Role))
|
||||
if role != "viewer" && role != "editor" {
|
||||
role = "editor"
|
||||
}
|
||||
|
||||
share, err := s.store.AddListShare(currentUserID, listID, targetType, req.TargetID, role)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if targetType == "team" {
|
||||
members, _ := s.store.GetTeamMembers(share.TargetID)
|
||||
for _, tm := range members {
|
||||
s.wsHub.BroadcastToUser(tm.UserID, "LIST_SHARED", share)
|
||||
}
|
||||
} else {
|
||||
s.wsHub.BroadcastToUser(share.TargetID, "LIST_SHARED", share)
|
||||
}
|
||||
s.wsHub.BroadcastListEvent(listID, "LIST_SHARED", share)
|
||||
|
||||
writeJSON(w, http.StatusCreated, share)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteListShare(w http.ResponseWriter, r *http.Request) {
|
||||
currentUserID := auth.GetUserID(r.Context())
|
||||
listID := chi.URLParam(r, "id")
|
||||
shareID := chi.URLParam(r, "shareId")
|
||||
|
||||
if err := s.store.RemoveListShare(currentUserID, listID, shareID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.wsHub.BroadcastListEvent(listID, "LIST_UNSHARED", map[string]string{
|
||||
"listId": listID,
|
||||
"shareId": shareID,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
|
||||
listID := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
@@ -1281,9 +1213,7 @@ func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
|
||||
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_CREATED", item)
|
||||
}
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
|
||||
writeJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
@@ -1301,7 +1231,7 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
user, _ := s.store.GetUserByID(userID)
|
||||
userName := "Familie"
|
||||
userName := "Mitglied"
|
||||
if user != nil {
|
||||
userName = user.DisplayName
|
||||
}
|
||||
@@ -1312,9 +1242,7 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
|
||||
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_TOGGLED", item)
|
||||
}
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item)
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
@@ -1328,12 +1256,10 @@ func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
|
||||
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_DELETED", map[string]string{
|
||||
"itemId": itemID,
|
||||
"listId": listID,
|
||||
})
|
||||
}
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_DELETED", map[string]string{
|
||||
"itemId": itemID,
|
||||
"listId": listID,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
@@ -1345,14 +1271,25 @@ func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
|
||||
s.wsHub.Broadcast(lst.SpaceID, listID, "LIST_CLEARED", lst)
|
||||
}
|
||||
s.wsHub.BroadcastListEvent(listID, "LIST_CLEARED", map[string]interface{}{
|
||||
"listId": listID,
|
||||
"removed": removed,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]int64{"removed": removed})
|
||||
}
|
||||
|
||||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
spaceID := chi.URLParam(r, "spaceId")
|
||||
roomID := chi.URLParam(r, "listId")
|
||||
if roomID == "" {
|
||||
roomID = chi.URLParam(r, "spaceId")
|
||||
}
|
||||
if roomID == "" {
|
||||
roomID = r.URL.Query().Get("listId")
|
||||
}
|
||||
if roomID == "" {
|
||||
roomID = r.URL.Query().Get("spaceId")
|
||||
}
|
||||
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
|
||||
@@ -1365,7 +1302,7 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
userID = claims.UserID
|
||||
}
|
||||
}
|
||||
s.wsHub.ServeWS(w, r, spaceID, userID)
|
||||
s.wsHub.ServeWS(w, r, roomID, userID)
|
||||
}
|
||||
|
||||
// --- API KEYS & WEBHOOK INGESTION ---
|
||||
|
||||
+39
-41
@@ -117,7 +117,6 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
|
||||
"/api/v1/tasks",
|
||||
"/api/v1/organizations",
|
||||
"/api/v1/teams",
|
||||
"/api/v1/spaces",
|
||||
"/api/v1/lists",
|
||||
}
|
||||
for _, ep := range arrayEndpoints {
|
||||
@@ -190,7 +189,6 @@ func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) {
|
||||
"/api/v1/auth/me",
|
||||
"/api/v1/triage/deck",
|
||||
"/api/v1/lists",
|
||||
"/api/v1/spaces",
|
||||
"/api/v1/tasks",
|
||||
"/api/v1/documents",
|
||||
}
|
||||
@@ -557,104 +555,104 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
|
||||
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))
|
||||
// 1. Admin creates a list
|
||||
createListBody := []byte(`{"title":"Einkauf WG","icon":"🛒","listType":"shopping"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/lists", bytes.NewReader(createListBody))
|
||||
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())
|
||||
t.Fatalf("expected 201 Created from /api/v1/lists, 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)
|
||||
var createdList domain.SharedList
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &createdList)
|
||||
if createdList.ID == "" {
|
||||
t.Fatalf("expected created list with ID, got %+v", createdList)
|
||||
}
|
||||
|
||||
// 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))
|
||||
// 2. Admin shares list with normalUser by username "user"
|
||||
addShareBody := []byte(`{"targetType":"user","targetId":"user","role":"editor"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addShareBody))
|
||||
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())
|
||||
t.Fatalf("expected 201 Created from add list share, 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)
|
||||
var newShare domain.ListShare
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &newShare)
|
||||
if newShare.TargetID != normalUser.ID {
|
||||
t.Fatalf("expected share targetId %s, got %s", normalUser.ID, newShare.TargetID)
|
||||
}
|
||||
|
||||
// 3. User checks their spaces; the shared space must be listed
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/spaces", nil)
|
||||
// 3. User checks their lists; the shared list must be listed
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/lists", 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)
|
||||
t.Fatalf("expected 200 OK for user lists, got %d", rec.Code)
|
||||
}
|
||||
var userSpaces []domain.Space
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &userSpaces)
|
||||
var userLists []domain.SharedList
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &userLists)
|
||||
found := false
|
||||
for _, s := range userSpaces {
|
||||
if s.ID == createdSpace.ID {
|
||||
for _, l := range userLists {
|
||||
if l.ID == createdList.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected space %s to be visible to user, got %+v", createdSpace.ID, userSpaces)
|
||||
t.Fatalf("expected list %s to be visible to user, got %+v", createdList.ID, userLists)
|
||||
}
|
||||
|
||||
// 4. Admin removes user from space
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/spaces/"+createdSpace.ID+"/members/"+normalUser.ID, nil)
|
||||
// 4. Admin removes user share from list
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/lists/"+createdList.ID+"/shares/"+newShare.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())
|
||||
t.Fatalf("expected 200 OK for remove share, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 5. Test adding a team to a space
|
||||
// 5. Test sharing list with a team
|
||||
team, err := store.CreateTeam("", "WG Team", "Haushalt")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create team: %v", err)
|
||||
}
|
||||
_ = store.AddTeamMember(team.ID, normalUser.ID, "member")
|
||||
|
||||
addTeamBody := []byte(`{"teamId":"` + team.ID + `","role":"editor"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+createdSpace.ID+"/members", bytes.NewReader(addTeamBody))
|
||||
addTeamBody := []byte(`{"targetType":"team","targetId":"` + team.ID + `","role":"editor"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addTeamBody))
|
||||
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 && rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 201 or 200 for add space team, got %d: %s", rec.Code, rec.Body.String())
|
||||
t.Fatalf("expected 201 or 200 for add list team share, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// User should see the space again because their team was added
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/spaces", nil)
|
||||
// User should see the list again because their team was added
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/lists", 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 after team add, got %d", rec.Code)
|
||||
t.Fatalf("expected 200 OK for user lists after team share add, got %d", rec.Code)
|
||||
}
|
||||
userSpaces = nil
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &userSpaces)
|
||||
userLists = nil
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &userLists)
|
||||
found = false
|
||||
for _, s := range userSpaces {
|
||||
if s.ID == createdSpace.ID {
|
||||
for _, l := range userLists {
|
||||
if l.ID == createdList.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected space %s to be visible to user via team, got %+v", createdSpace.ID, userSpaces)
|
||||
t.Fatalf("expected list %s to be visible to user via team share, got %+v", createdList.ID, userLists)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-36
@@ -324,40 +324,18 @@ type TagDetails struct {
|
||||
TotalMatches int `json:"totalMatches"`
|
||||
}
|
||||
|
||||
type SpaceMember struct {
|
||||
SpaceID string `json:"spaceId"`
|
||||
UserID string `json:"userId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"` // owner | editor | viewer
|
||||
JoinedAt string `json:"joinedAt"`
|
||||
}
|
||||
|
||||
type SpaceShare struct {
|
||||
type ListShare struct {
|
||||
ID string `json:"id"`
|
||||
SpaceID string `json:"spaceId"`
|
||||
ListID string `json:"listId"`
|
||||
TargetType string `json:"targetType"` // team | user
|
||||
TargetID string `json:"targetId"`
|
||||
TargetName string `json:"targetName,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Role string `json:"role"` // editor | viewer
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Space struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Description string `json:"description"`
|
||||
InviteCode string `json:"inviteCode"`
|
||||
IsPersonal bool `json:"isPersonal"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
Members []SpaceMember `json:"members"`
|
||||
Shares []SpaceShare `json:"shares,omitempty"`
|
||||
ListsCount int `json:"listsCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type ListItem struct {
|
||||
ID string `json:"id"`
|
||||
ListID string `json:"listId"`
|
||||
@@ -373,14 +351,18 @@ type ListItem struct {
|
||||
}
|
||||
|
||||
type SharedList struct {
|
||||
ID string `json:"id"`
|
||||
SpaceID string `json:"spaceId"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
ListType string `json:"listType"` // shopping | checklist | vacation
|
||||
Items []ListItem `json:"items"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Role string `json:"role,omitempty"` // editor | viewer
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
ListType string `json:"listType"` // shopping | checklist | vacation
|
||||
Items []ListItem `json:"items"`
|
||||
Shares []ListShare `json:"shares,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
@@ -397,8 +379,7 @@ type APIKey struct {
|
||||
|
||||
type WSMessage struct {
|
||||
Event string `json:"event"` // ITEM_CREATED | ITEM_UPDATED | ITEM_TOGGLED | ITEM_DELETED | LIST_CLEARED | USER_PRESENCE
|
||||
SpaceID string `json:"space_id"`
|
||||
ListID string `json:"list_id,omitempty"`
|
||||
ListID string `json:"list_id"`
|
||||
Payload interface{} `json:"payload"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
@@ -419,7 +400,6 @@ type FullExport struct {
|
||||
User User `json:"user"`
|
||||
Thoughts []Thought `json:"thoughts"`
|
||||
Documents []Document `json:"documents"`
|
||||
Spaces []Space `json:"spaces"`
|
||||
Lists []SharedList `json:"lists"`
|
||||
TaskLists []TaskList `json:"taskLists,omitempty"`
|
||||
}
|
||||
+233
-417
@@ -137,28 +137,6 @@ func (s *Store) migrate() error {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_lists_user ON task_lists(user_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS spaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '🏠',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
invite_code TEXT UNIQUE NOT NULL,
|
||||
is_personal INTEGER NOT NULL DEFAULT 0,
|
||||
owner_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS space_members (
|
||||
space_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'editor',
|
||||
joined_at TEXT NOT NULL,
|
||||
PRIMARY KEY (space_id, user_id),
|
||||
FOREIGN KEY(space_id) REFERENCES spaces(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -259,16 +237,32 @@ func (s *Store) migrate() error {
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lists (
|
||||
id TEXT PRIMARY KEY,
|
||||
space_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '🛒',
|
||||
list_type TEXT NOT NULL DEFAULT 'shopping',
|
||||
updated_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(space_id) REFERENCES spaces(id) ON DELETE CASCADE
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lists_space ON lists(space_id, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_lists_user ON lists(user_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS list_shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
list_id TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')),
|
||||
target_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('editor', 'viewer')),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(list_id, target_type, target_id),
|
||||
FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_list_shares_target ON list_shares(target_type, target_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_list_shares_list ON list_shares(list_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS list_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -342,14 +336,20 @@ func (s *Store) migrate() error {
|
||||
"CREATE INDEX IF NOT EXISTS idx_doc_team_shares_team ON doc_team_shares(team_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_doc_team_shares_doc ON doc_team_shares(doc_id);",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS space_team_shares (id TEXT PRIMARY KEY, space_id TEXT NOT NULL, team_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'editor', created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(space_id, team_id));",
|
||||
"CREATE INDEX IF NOT EXISTS idx_space_team_shares_team ON space_team_shares(team_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_space_team_shares_space ON space_team_shares(space_id);",
|
||||
"ALTER TABLE lists ADD COLUMN user_id TEXT NOT NULL DEFAULT '';",
|
||||
"CREATE TABLE IF NOT EXISTS list_shares (id TEXT PRIMARY KEY, list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('editor', 'viewer')), created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(list_id, target_type, target_id));",
|
||||
"CREATE INDEX IF NOT EXISTS idx_list_shares_target ON list_shares(target_type, target_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_list_shares_list ON list_shares(list_id);",
|
||||
}
|
||||
for _, q := range columnMigrations {
|
||||
_, _ = s.db.Exec(q)
|
||||
}
|
||||
|
||||
// Clean up legacy space_id from documents and task_lists if empty or orphaned
|
||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS spaces`)
|
||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS space_members`)
|
||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS space_team_shares`)
|
||||
|
||||
// Backfill username for existing accounts where username is empty
|
||||
_, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`)
|
||||
|
||||
@@ -442,13 +442,10 @@ func (s *Store) SeedStarterContent(user *domain.User) error {
|
||||
_, _ = s.UpdateThoughtTriage(user.ID, keptTask.ID, "keep", "task", "", now[:10], "")
|
||||
}
|
||||
|
||||
familySpace, err := s.CreateSpace(user.ID, "Familie & Zuhause", "🏡", "Geteilter Bereich für Einkäufe, Haushalt & Urlaubsplanung", false)
|
||||
if err == nil && familySpace != nil {
|
||||
shopList, err := s.CreateList(familySpace.ID, "Wocheneinkauf Supermarkt", "🛒", "shopping")
|
||||
if err == nil && shopList != nil {
|
||||
_, _ = s.CreateListItem(shopList.ID, "Hafermilch (Barista Edition)", "Kühlregal", "3x", 0)
|
||||
_, _ = s.CreateListItem(shopList.ID, "Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", 1)
|
||||
}
|
||||
shopList, err := s.CreateList(user.ID, "Wocheneinkauf Supermarkt", "🛒", "shopping")
|
||||
if err == nil && shopList != nil {
|
||||
_, _ = s.CreateListItem(shopList.ID, "Hafermilch (Barista Edition)", "Kühlregal", "3x", 0)
|
||||
_, _ = s.CreateListItem(shopList.ID, "Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", 1)
|
||||
}
|
||||
|
||||
starterBlocks := []domain.DocumentBlock{
|
||||
@@ -692,12 +689,8 @@ func (s *Store) GetDiscoverableUsers(viewerID string) ([]domain.User, error) {
|
||||
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
|
||||
@@ -879,15 +872,14 @@ func (s *Store) accessibleTaskListIDsQuery() string {
|
||||
SELECT task_list_id FROM task_list_shares
|
||||
WHERE (target_type = 'user' AND target_id = ?)
|
||||
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))`
|
||||
)`
|
||||
}
|
||||
|
||||
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
|
||||
row := s.db.QueryRow(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
||||
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
|
||||
thoughtID, userID, userID, userID, userID, userID, userID,
|
||||
thoughtID, userID, userID, userID, userID, userID,
|
||||
)
|
||||
return scanThought(row)
|
||||
}
|
||||
@@ -940,7 +932,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
||||
AND task_list_id = ?
|
||||
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
||||
ORDER BY is_completed ASC, CASE WHEN due_date = '' THEN 1 ELSE 0 END, due_date ASC, created_at DESC`,
|
||||
userID, userID, userID, userID, userID, userID, taskListID,
|
||||
userID, userID, userID, userID, userID, taskListID,
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.Query(
|
||||
@@ -949,7 +941,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
||||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
||||
ORDER BY is_completed ASC, CASE WHEN due_date = '' THEN 1 ELSE 0 END, due_date ASC, created_at DESC`,
|
||||
userID, userID, userID, userID, userID, userID,
|
||||
userID, userID, userID, userID, userID,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -2190,339 +2182,9 @@ func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) {
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// --- SPACES & REALTIME LISTS ---
|
||||
// --- STANDALONE REALTIME LISTS & SHARING ---
|
||||
|
||||
func (s *Store) CreateSpace(ownerID, name, icon, description string, isPersonal bool) (*domain.Space, error) {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return nil, errors.New("space name required")
|
||||
}
|
||||
if icon == "" {
|
||||
icon = "🏠"
|
||||
}
|
||||
now := domain.NowISO()
|
||||
inviteCode := strings.ToUpper(domain.NewID("inv")[4:12])
|
||||
personalInt := 0
|
||||
if isPersonal {
|
||||
personalInt = 1
|
||||
}
|
||||
|
||||
sp := &domain.Space{
|
||||
ID: domain.NewID("spc"),
|
||||
Name: strings.TrimSpace(name),
|
||||
Icon: icon,
|
||||
Description: strings.TrimSpace(description),
|
||||
InviteCode: inviteCode,
|
||||
IsPersonal: isPersonal,
|
||||
OwnerID: ownerID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO spaces (id, name, icon, description, invite_code, is_personal, owner_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
sp.ID, sp.Name, sp.Icon, sp.Description, sp.InviteCode, personalInt, sp.OwnerID, sp.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, _ = s.db.Exec(
|
||||
`INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, 'owner', ?)`,
|
||||
sp.ID, ownerID, now,
|
||||
)
|
||||
return s.GetSpaceByID(sp.ID)
|
||||
}
|
||||
|
||||
func (s *Store) JoinSpaceByInviteCode(userID, inviteCode string) (*domain.Space, error) {
|
||||
inviteCode = strings.ToUpper(strings.TrimSpace(inviteCode))
|
||||
var spaceID string
|
||||
err := s.db.QueryRow(`SELECT id FROM spaces WHERE invite_code = ?`, inviteCode).Scan(&spaceID)
|
||||
if err != nil {
|
||||
return nil, errors.New("Ungültiger Einladungscode")
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(
|
||||
`INSERT OR IGNORE INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, 'editor', ?)`,
|
||||
spaceID, userID, domain.NowISO(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetSpaceByID(spaceID)
|
||||
}
|
||||
|
||||
func (s *Store) GetSpaceByID(spaceID string) (*domain.Space, error) {
|
||||
sp := &domain.Space{}
|
||||
var personalInt int
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, name, icon, description, invite_code, is_personal, owner_id, created_at FROM spaces WHERE id = ?`,
|
||||
spaceID,
|
||||
).Scan(&sp.ID, &sp.Name, &sp.Icon, &sp.Description, &sp.InviteCode, &personalInt, &sp.OwnerID, &sp.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sp.IsPersonal = personalInt != 0
|
||||
sp.Members, _ = s.GetSpaceMembers(spaceID)
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaceID).Scan(&sp.ListsCount)
|
||||
return sp, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetSpaceMembers(spaceID string) ([]domain.SpaceMember, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT sm.space_id, sm.user_id, u.display_name, u.email, sm.role, sm.joined_at
|
||||
FROM space_members sm
|
||||
JOIN users u ON u.id = sm.user_id
|
||||
WHERE sm.space_id = ?
|
||||
ORDER BY sm.joined_at ASC`,
|
||||
spaceID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
members := make([]domain.SpaceMember, 0)
|
||||
for rows.Next() {
|
||||
var m domain.SpaceMember
|
||||
if err := rows.Scan(&m.SpaceID, &m.UserID, &m.DisplayName, &m.Email, &m.Role, &m.JoinedAt); err == nil {
|
||||
members = append(members, m)
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
// Protect existing owner from role downgrade!
|
||||
var existingRole string
|
||||
_ = s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, targetUser.ID).Scan(&existingRole)
|
||||
if existingRole == "owner" {
|
||||
role = "owner"
|
||||
} else 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) CreateSpaceTeamShare(currentUserID, spaceID, teamID, role string) (*domain.SpaceShare, 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 || (myRole != "owner" && myRole != "editor") {
|
||||
return nil, errors.New("keine Berechtigung für diesen Bereich")
|
||||
}
|
||||
|
||||
cleanTeamID := strings.TrimSpace(teamID)
|
||||
var teamName string
|
||||
err = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTeamID).Scan(&teamName)
|
||||
if err != nil {
|
||||
return nil, errors.New("team nicht gefunden")
|
||||
}
|
||||
|
||||
if role != "owner" && role != "editor" && role != "viewer" {
|
||||
role = "editor"
|
||||
}
|
||||
|
||||
now := domain.NowISO()
|
||||
shareID := domain.NewID("spshare")
|
||||
_, err = s.db.Exec(
|
||||
`INSERT INTO space_team_shares (id, space_id, team_id, role, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(space_id, team_id) DO UPDATE SET role = excluded.role`,
|
||||
shareID, spaceID, cleanTeamID, role, now, currentUserID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &domain.SpaceShare{
|
||||
ID: shareID,
|
||||
SpaceID: spaceID,
|
||||
TargetType: "team",
|
||||
TargetID: cleanTeamID,
|
||||
TargetName: teamName,
|
||||
Role: role,
|
||||
CreatedBy: currentUserID,
|
||||
CreatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetSpaceShares(spaceID string) ([]domain.SpaceShare, error) {
|
||||
shares := make([]domain.SpaceShare, 0)
|
||||
|
||||
// 1. Team shares
|
||||
tRows, err := s.db.Query(
|
||||
`SELECT s.id, s.space_id, s.team_id, t.name, s.role, s.created_by, s.created_at
|
||||
FROM space_team_shares s
|
||||
JOIN teams t ON s.team_id = t.id
|
||||
WHERE s.space_id = ?
|
||||
ORDER BY s.created_at ASC`,
|
||||
spaceID,
|
||||
)
|
||||
if err == nil {
|
||||
for tRows.Next() {
|
||||
var sh domain.SpaceShare
|
||||
if err := tRows.Scan(&sh.ID, &sh.SpaceID, &sh.TargetID, &sh.TargetName, &sh.Role, &sh.CreatedBy, &sh.CreatedAt); err == nil {
|
||||
sh.TargetType = "team"
|
||||
shares = append(shares, sh)
|
||||
}
|
||||
}
|
||||
_ = tRows.Close()
|
||||
}
|
||||
|
||||
// 2. Individual invited members (non-owner members)
|
||||
var ownerID string
|
||||
_ = s.db.QueryRow(`SELECT owner_id FROM spaces WHERE id = ?`, spaceID).Scan(&ownerID)
|
||||
|
||||
mRows, err := s.db.Query(
|
||||
`SELECT sm.user_id, u.display_name, u.email, sm.role, sm.joined_at
|
||||
FROM space_members sm
|
||||
JOIN users u ON u.id = sm.user_id
|
||||
WHERE sm.space_id = ? AND sm.user_id != ?
|
||||
ORDER BY sm.joined_at ASC`,
|
||||
spaceID, ownerID,
|
||||
)
|
||||
if err == nil {
|
||||
for mRows.Next() {
|
||||
var uid, dName, email, role, joinedAt string
|
||||
if err := mRows.Scan(&uid, &dName, &email, &role, &joinedAt); err == nil {
|
||||
name := dName
|
||||
if name == "" {
|
||||
name = email
|
||||
}
|
||||
shares = append(shares, domain.SpaceShare{
|
||||
ID: "sm_" + uid,
|
||||
SpaceID: spaceID,
|
||||
TargetType: "user",
|
||||
TargetID: uid,
|
||||
TargetName: name,
|
||||
Role: role,
|
||||
CreatedAt: joinedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
_ = mRows.Close()
|
||||
}
|
||||
|
||||
return shares, nil
|
||||
}
|
||||
|
||||
func (s *Store) RemoveSpaceTeamShare(currentUserID, spaceID, targetID 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 || (myRole != "owner" && myRole != "editor") {
|
||||
return errors.New("keine Berechtigung zum Entfernen der Freigabe")
|
||||
}
|
||||
|
||||
cleanTarget := strings.TrimSpace(targetID)
|
||||
_, err = s.db.Exec(`DELETE FROM space_team_shares WHERE space_id = ? AND (id = ? OR team_id = ?)`, spaceID, cleanTarget, cleanTarget)
|
||||
return err
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
cleanTarget := strings.TrimSpace(targetUserID)
|
||||
if strings.HasPrefix(cleanTarget, "team_") || strings.HasPrefix(cleanTarget, "spshare_") {
|
||||
return s.RemoveSpaceTeamShare(currentUserID, spaceID, cleanTarget)
|
||||
}
|
||||
|
||||
if currentUserID != cleanTarget && 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, cleanTarget).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, cleanTarget)
|
||||
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
|
||||
FROM spaces s
|
||||
LEFT JOIN space_members sm ON sm.space_id = s.id AND sm.user_id = ?
|
||||
LEFT JOIN space_team_shares sts ON sts.space_id = s.id AND sts.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)
|
||||
WHERE sm.user_id = ? OR sts.team_id IS NOT NULL
|
||||
GROUP BY s.id
|
||||
ORDER BY s.created_at ASC`,
|
||||
userID, userID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spaces := make([]domain.Space, 0)
|
||||
for rows.Next() {
|
||||
var sp domain.Space
|
||||
var personalInt int
|
||||
if err := rows.Scan(&sp.ID, &sp.Name, &sp.Icon, &sp.Description, &sp.InviteCode, &personalInt, &sp.OwnerID, &sp.CreatedAt); err == nil {
|
||||
sp.IsPersonal = personalInt != 0
|
||||
spaces = append(spaces, sp)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for i := range spaces {
|
||||
spaces[i].Members, _ = s.GetSpaceMembers(spaces[i].ID)
|
||||
spaces[i].Shares, _ = s.GetSpaceShares(spaces[i].ID)
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaces[i].ID).Scan(&spaces[i].ListsCount)
|
||||
}
|
||||
return spaces, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateList(spaceID, title, icon, listType string) (*domain.SharedList, error) {
|
||||
func (s *Store) CreateList(userID, title, icon, listType string) (*domain.SharedList, error) {
|
||||
if strings.TrimSpace(title) == "" {
|
||||
return nil, errors.New("list title required")
|
||||
}
|
||||
@@ -2535,59 +2197,167 @@ func (s *Store) CreateList(spaceID, title, icon, listType string) (*domain.Share
|
||||
now := domain.NowISO()
|
||||
lst := &domain.SharedList{
|
||||
ID: domain.NewID("lst"),
|
||||
SpaceID: spaceID,
|
||||
UserID: userID,
|
||||
IsOwner: true,
|
||||
Role: "editor",
|
||||
Title: strings.TrimSpace(title),
|
||||
Icon: icon,
|
||||
ListType: listType,
|
||||
Items: []domain.ListItem{},
|
||||
Shares: []domain.ListShare{},
|
||||
UpdatedAt: now,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO lists (id, space_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
lst.ID, lst.SpaceID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt,
|
||||
`INSERT INTO lists (id, user_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
lst.ID, lst.UserID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&lst.OwnerName)
|
||||
return lst, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetListsForSpace(spaceID string) ([]domain.SharedList, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, space_id, title, icon, list_type, updated_at, created_at
|
||||
FROM lists WHERE space_id = ? ORDER BY created_at ASC`,
|
||||
spaceID,
|
||||
func (s *Store) AddListShare(currentUserID, listID, targetType, targetID, role string) (*domain.ListShare, error) {
|
||||
if targetType != "team" && targetType != "user" {
|
||||
return nil, errors.New("ungültiger Freigabetyp")
|
||||
}
|
||||
targetID = strings.TrimSpace(targetID)
|
||||
if targetID == "" {
|
||||
return nil, errors.New("Ziel-ID erforderlich")
|
||||
}
|
||||
if role != "viewer" && role != "editor" {
|
||||
role = "editor"
|
||||
}
|
||||
|
||||
if targetType == "user" {
|
||||
var targetUser *domain.User
|
||||
if strings.HasPrefix(targetID, "usr_") {
|
||||
targetUser, _ = s.GetUserByID(targetID)
|
||||
}
|
||||
if targetUser == nil {
|
||||
targetUser, _ = s.GetUserByLogin(targetID)
|
||||
}
|
||||
if targetUser == nil {
|
||||
targetUser, _ = s.GetUserByEmail(targetID)
|
||||
}
|
||||
if targetUser == nil {
|
||||
return nil, fmt.Errorf("benutzer nicht gefunden: %s", targetID)
|
||||
}
|
||||
targetID = targetUser.ID
|
||||
}
|
||||
|
||||
// Verify current user is owner or editor of the list
|
||||
var ownerID string
|
||||
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
|
||||
if err != nil {
|
||||
return nil, errors.New("liste nicht gefunden")
|
||||
}
|
||||
if ownerID != currentUserID {
|
||||
var myRole string
|
||||
err = s.db.QueryRow(`SELECT role FROM list_shares WHERE list_id = ? AND target_type = 'user' AND target_id = ?`, listID, currentUserID).Scan(&myRole)
|
||||
if err != nil || myRole != "editor" {
|
||||
return nil, errors.New("keine Berechtigung zum Teilen dieser Liste")
|
||||
}
|
||||
}
|
||||
|
||||
now := domain.NowISO()
|
||||
share := &domain.ListShare{
|
||||
ID: domain.NewID("lsh"),
|
||||
ListID: listID,
|
||||
TargetType: targetType,
|
||||
TargetID: targetID,
|
||||
Role: role,
|
||||
CreatedBy: currentUserID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(
|
||||
`INSERT INTO list_shares (id, list_id, target_type, target_id, role, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(list_id, target_type, target_id) DO UPDATE SET role = excluded.role`,
|
||||
share.ID, share.ListID, share.TargetType, share.TargetID, share.Role, share.CreatedBy, share.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lists := make([]domain.SharedList, 0)
|
||||
_ = s.db.QueryRow(`SELECT id, created_at FROM list_shares WHERE list_id = ? AND target_type = ? AND target_id = ?`,
|
||||
listID, targetType, targetID).Scan(&share.ID, &share.CreatedAt)
|
||||
|
||||
if targetType == "team" {
|
||||
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, targetID).Scan(&share.TargetName)
|
||||
} else {
|
||||
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, targetID).Scan(&share.TargetName, &share.Email)
|
||||
}
|
||||
return share, nil
|
||||
}
|
||||
|
||||
func (s *Store) RemoveListShare(currentUserID, listID, shareIDOrTargetID string) error {
|
||||
var ownerID string
|
||||
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
|
||||
if err != nil {
|
||||
return errors.New("liste nicht gefunden")
|
||||
}
|
||||
if ownerID != currentUserID {
|
||||
return errors.New("nur der Eigentümer kann Freigaben entfernen")
|
||||
}
|
||||
|
||||
cleanTarget := strings.TrimSpace(shareIDOrTargetID)
|
||||
_, err = s.db.Exec(
|
||||
`DELETE FROM list_shares WHERE list_id = ? AND (id = ? OR target_id = ?)`,
|
||||
listID, cleanTarget, cleanTarget,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetListShares(listID string) ([]domain.ListShare, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, list_id, target_type, target_id, role, created_by, created_at
|
||||
FROM list_shares WHERE list_id = ? ORDER BY created_at ASC`,
|
||||
listID,
|
||||
)
|
||||
if err != nil {
|
||||
return []domain.ListShare{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
shares := make([]domain.ListShare, 0)
|
||||
for rows.Next() {
|
||||
var l domain.SharedList
|
||||
if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil {
|
||||
lists = append(lists, l)
|
||||
var sh domain.ListShare
|
||||
if err := rows.Scan(&sh.ID, &sh.ListID, &sh.TargetType, &sh.TargetID, &sh.Role, &sh.CreatedBy, &sh.CreatedAt); err == nil {
|
||||
if sh.TargetType == "team" {
|
||||
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName)
|
||||
} else {
|
||||
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName, &sh.Email)
|
||||
}
|
||||
shares = append(shares, sh)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for i := range lists {
|
||||
lists[i].Items, _ = s.GetListItems(lists[i].ID)
|
||||
}
|
||||
return lists, nil
|
||||
return shares, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT DISTINCT l.id, l.space_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at
|
||||
`SELECT DISTINCT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at,
|
||||
u.display_name,
|
||||
CASE
|
||||
WHEN l.user_id = ? THEN 'owner'
|
||||
WHEN ls.role IS NOT NULL THEN ls.role
|
||||
ELSE 'editor'
|
||||
END as effective_role
|
||||
FROM lists l
|
||||
LEFT JOIN space_members sm ON sm.space_id = l.space_id AND sm.user_id = ?
|
||||
LEFT JOIN space_team_shares sts ON sts.space_id = l.space_id AND sts.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)
|
||||
WHERE sm.user_id = ? OR sts.team_id IS NOT NULL
|
||||
LEFT JOIN users u ON u.id = l.user_id
|
||||
LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
|
||||
(ls.target_type = 'user' AND ls.target_id = ?)
|
||||
OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
WHERE l.user_id = ?
|
||||
OR ls.id IS NOT NULL
|
||||
ORDER BY l.updated_at DESC`,
|
||||
userID, userID, userID,
|
||||
userID, userID, userID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -2596,7 +2366,12 @@ func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
|
||||
lists := make([]domain.SharedList, 0)
|
||||
for rows.Next() {
|
||||
var l domain.SharedList
|
||||
if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil {
|
||||
var ownerName sql.NullString
|
||||
var effectiveRole string
|
||||
if err := rows.Scan(&l.ID, &l.UserID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt, &ownerName, &effectiveRole); err == nil {
|
||||
l.OwnerName = ownerName.String
|
||||
l.IsOwner = (l.UserID == userID)
|
||||
l.Role = effectiveRole
|
||||
lists = append(lists, l)
|
||||
}
|
||||
}
|
||||
@@ -2604,25 +2379,68 @@ func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
|
||||
|
||||
for i := range lists {
|
||||
lists[i].Items, _ = s.GetListItems(lists[i].ID)
|
||||
lists[i].Shares, _ = s.GetListShares(lists[i].ID)
|
||||
}
|
||||
return lists, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetListByID(listID string) (*domain.SharedList, error) {
|
||||
func (s *Store) GetListByID(userID, listID string) (*domain.SharedList, error) {
|
||||
var l domain.SharedList
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, space_id, title, icon, list_type, updated_at, created_at FROM lists WHERE id = ?`,
|
||||
listID,
|
||||
).Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt)
|
||||
var ownerName sql.NullString
|
||||
var effectiveRole string
|
||||
|
||||
var query string
|
||||
var args []interface{}
|
||||
if userID == "" {
|
||||
query = `SELECT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at,
|
||||
u.display_name, 'owner' as effective_role
|
||||
FROM lists l
|
||||
LEFT JOIN users u ON u.id = l.user_id
|
||||
WHERE l.id = ?`
|
||||
args = []interface{}{listID}
|
||||
} else {
|
||||
query = `SELECT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at,
|
||||
u.display_name,
|
||||
CASE
|
||||
WHEN l.user_id = ? THEN 'owner'
|
||||
WHEN ls.role IS NOT NULL THEN ls.role
|
||||
ELSE 'editor'
|
||||
END as effective_role
|
||||
FROM lists l
|
||||
LEFT JOIN users u ON u.id = l.user_id
|
||||
LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
|
||||
(ls.target_type = 'user' AND ls.target_id = ?)
|
||||
OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
WHERE l.id = ? AND (
|
||||
l.user_id = ?
|
||||
OR ls.id IS NOT NULL
|
||||
)`
|
||||
args = []interface{}{userID, userID, userID, listID, userID}
|
||||
}
|
||||
|
||||
err := s.db.QueryRow(query, args...).Scan(&l.ID, &l.UserID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt, &ownerName, &effectiveRole)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.OwnerName = ownerName.String
|
||||
l.IsOwner = (userID == "" || l.UserID == userID)
|
||||
l.Role = effectiveRole
|
||||
l.Items, _ = s.GetListItems(l.ID)
|
||||
l.Shares, _ = s.GetListShares(l.ID)
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteList(listID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM lists WHERE id = ?`, listID)
|
||||
func (s *Store) DeleteList(userID, listID string) error {
|
||||
var ownerID string
|
||||
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
|
||||
if err != nil {
|
||||
return errors.New("liste nicht gefunden")
|
||||
}
|
||||
if ownerID != userID {
|
||||
return errors.New("nur der Eigentümer kann die Liste löschen")
|
||||
}
|
||||
_, err = s.db.Exec(`DELETE FROM lists WHERE id = ?`, listID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2829,9 +2647,11 @@ func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error)
|
||||
_ = s.db.QueryRow(`
|
||||
SELECT COUNT(DISTINCT l.id)
|
||||
FROM lists l
|
||||
LEFT JOIN space_members sm ON sm.space_id = l.space_id AND sm.user_id = ?
|
||||
LEFT JOIN space_team_shares sts ON sts.space_id = l.space_id AND sts.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)
|
||||
WHERE sm.user_id = ? OR sts.team_id IS NOT NULL`,
|
||||
LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
|
||||
(ls.target_type = 'user' AND ls.target_id = ?)
|
||||
OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
WHERE l.user_id = ? OR ls.id IS NOT NULL`,
|
||||
userID, userID, userID).Scan(&st.ListsCount)
|
||||
|
||||
// Count open (uncompleted) To-Dos across accessible workspace documents (owned or shared)
|
||||
@@ -2908,7 +2728,6 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {
|
||||
}
|
||||
thoughts, _ := s.GetThoughts(userID, "all", 5000)
|
||||
docs, _ := s.GetDocuments(userID)
|
||||
spaces, _ := s.GetSpacesForUser(userID)
|
||||
lists, _ := s.GetAllListsForUser(userID)
|
||||
taskLists, _ := s.GetTaskLists(userID)
|
||||
|
||||
@@ -2918,7 +2737,6 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {
|
||||
User: *user,
|
||||
Thoughts: thoughts,
|
||||
Documents: docs,
|
||||
Spaces: spaces,
|
||||
Lists: lists,
|
||||
TaskLists: taskLists,
|
||||
}, nil
|
||||
@@ -3127,9 +2945,8 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
|
||||
WHERE (target_type = 'user' AND target_id = ?)
|
||||
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))
|
||||
ORDER BY updated_at DESC`,
|
||||
userID, userID, userID, userID, userID,
|
||||
userID, userID, userID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return []domain.TaskList{}, err
|
||||
@@ -3192,9 +3009,8 @@ func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error)
|
||||
WHERE (target_type = 'user' AND target_id = ?)
|
||||
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))
|
||||
)`,
|
||||
listID, userID, userID, userID, userID, userID,
|
||||
listID, userID, userID, userID, userID,
|
||||
).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -48,12 +48,8 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
t.Fatalf("expected converted document, got status=%s type=%s ref=%s", triaged.Status, triaged.ConvertedType, triaged.ConvertedRefID)
|
||||
}
|
||||
|
||||
// 3. Test Shared Space & Shopping List
|
||||
sp, err := store.CreateSpace(user.ID, "Test WG", "🍕", "Test Space", false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSpace failed: %v", err)
|
||||
}
|
||||
lst, err := store.CreateList(sp.ID, "REWE Einkauf", "🛒", "shopping")
|
||||
// 3. Test Standalone Shopping List & Sharing
|
||||
lst, err := store.CreateList(user.ID, "REWE Einkauf", "🛒", "shopping")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateList failed: %v", err)
|
||||
}
|
||||
@@ -66,26 +62,26 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
t.Fatalf("ToggleListItem failed: %v", err)
|
||||
}
|
||||
|
||||
// Test AddSpaceMember & RemoveSpaceMember
|
||||
// Test AddListShare & RemoveListShare
|
||||
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)
|
||||
share, err := store.AddListShare(user.ID, lst.ID, "user", u2.ID, "editor")
|
||||
if err != nil || share.TargetID != u2.ID {
|
||||
t.Fatalf("AddListShare failed: %v", err)
|
||||
}
|
||||
members, err := store.GetSpaceMembers(sp.ID)
|
||||
if err != nil || len(members) != 2 {
|
||||
t.Fatalf("expected 2 members, got %d", len(members))
|
||||
shares, err := store.GetListShares(lst.ID)
|
||||
if err != nil || len(shares) != 1 {
|
||||
t.Fatalf("expected 1 share, got %d", len(shares))
|
||||
}
|
||||
err = store.RemoveSpaceMember(user.ID, sp.ID, u2.ID)
|
||||
err = store.RemoveListShare(user.ID, lst.ID, share.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveSpaceMember failed: %v", err)
|
||||
t.Fatalf("RemoveListShare failed: %v", err)
|
||||
}
|
||||
membersAfter, _ := store.GetSpaceMembers(sp.ID)
|
||||
if len(membersAfter) != 1 {
|
||||
t.Fatalf("expected 1 member after remove, got %d", len(membersAfter))
|
||||
sharesAfter, _ := store.GetListShares(lst.ID)
|
||||
if len(sharesAfter) != 0 {
|
||||
t.Fatalf("expected 0 shares after remove, got %d", len(sharesAfter))
|
||||
}
|
||||
|
||||
// 4. Test Webhook API Key generation & validation
|
||||
@@ -99,7 +95,7 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
// 5. Test TaskLists CRUD
|
||||
taskList, err := store.CreateTaskList(user.ID, sp.ID, "", "Haus & Garten", "🏡", "#10b981")
|
||||
taskList, err := store.CreateTaskList(user.ID, "", "", "Haus & Garten", "🏡", "#10b981")
|
||||
if err != nil || taskList.ID == "" {
|
||||
t.Fatalf("CreateTaskList failed: %v", err)
|
||||
}
|
||||
@@ -160,8 +156,8 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
// Share with user2 as "read"
|
||||
share, err := store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "read")
|
||||
if err != nil || share.Permission != "read" {
|
||||
docShare, err := store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "read")
|
||||
if err != nil || docShare.Permission != "read" {
|
||||
t.Fatalf("CreateDocShare failed: %v", err)
|
||||
}
|
||||
|
||||
|
||||
+29
-23
@@ -22,7 +22,7 @@ var upgrader = websocket.Upgrader{
|
||||
type Client struct {
|
||||
hub *Hub
|
||||
conn *websocket.Conn
|
||||
spaceID string
|
||||
roomID string
|
||||
userID string
|
||||
send chan []byte
|
||||
}
|
||||
@@ -37,7 +37,7 @@ type docLockItem struct {
|
||||
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
spaceRooms map[string]map[*Client]bool
|
||||
rooms map[string]map[*Client]bool
|
||||
userRooms map[string]map[*Client]bool
|
||||
allClients map[*Client]bool
|
||||
locks map[string]*docLockItem
|
||||
@@ -45,7 +45,7 @@ type Hub struct {
|
||||
|
||||
func NewHub() *Hub {
|
||||
h := &Hub{
|
||||
spaceRooms: make(map[string]map[*Client]bool),
|
||||
rooms: make(map[string]map[*Client]bool),
|
||||
userRooms: make(map[string]map[*Client]bool),
|
||||
allClients: make(map[*Client]bool),
|
||||
locks: make(map[string]*docLockItem),
|
||||
@@ -57,11 +57,11 @@ func NewHub() *Hub {
|
||||
func (h *Hub) Register(c *Client) {
|
||||
h.mu.Lock()
|
||||
h.allClients[c] = true
|
||||
if c.spaceID != "" {
|
||||
if h.spaceRooms[c.spaceID] == nil {
|
||||
h.spaceRooms[c.spaceID] = make(map[*Client]bool)
|
||||
if c.roomID != "" {
|
||||
if h.rooms[c.roomID] == nil {
|
||||
h.rooms[c.roomID] = make(map[*Client]bool)
|
||||
}
|
||||
h.spaceRooms[c.spaceID][c] = true
|
||||
h.rooms[c.roomID][c] = true
|
||||
}
|
||||
if c.userID != "" && c.userID != "anonymous" {
|
||||
if h.userRooms[c.userID] == nil {
|
||||
@@ -72,8 +72,8 @@ func (h *Hub) Register(c *Client) {
|
||||
onlineCount := len(h.allClients)
|
||||
h.mu.Unlock()
|
||||
|
||||
if c.spaceID != "" {
|
||||
h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{
|
||||
if c.roomID != "" {
|
||||
h.Broadcast(c.roomID, "", "USER_PRESENCE", map[string]interface{}{
|
||||
"onlineCount": onlineCount,
|
||||
})
|
||||
}
|
||||
@@ -82,11 +82,11 @@ func (h *Hub) Register(c *Client) {
|
||||
func (h *Hub) Unregister(c *Client) {
|
||||
h.mu.Lock()
|
||||
delete(h.allClients, c)
|
||||
if c.spaceID != "" {
|
||||
if room, ok := h.spaceRooms[c.spaceID]; ok {
|
||||
if c.roomID != "" {
|
||||
if room, ok := h.rooms[c.roomID]; ok {
|
||||
delete(room, c)
|
||||
if len(room) == 0 {
|
||||
delete(h.spaceRooms, c.spaceID)
|
||||
delete(h.rooms, c.roomID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,7 +297,6 @@ func (h *Hub) BroadcastDocEvent(docID, event string, payload interface{}) {
|
||||
func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
||||
msg := domain.WSMessage{
|
||||
Event: event,
|
||||
SpaceID: spaceID,
|
||||
ListID: listID,
|
||||
Payload: payload,
|
||||
Timestamp: domain.NowISO(),
|
||||
@@ -310,20 +309,27 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
room, ok := h.spaceRooms[spaceID]
|
||||
if !ok {
|
||||
return
|
||||
targetRoom := listID
|
||||
if targetRoom == "" {
|
||||
targetRoom = spaceID
|
||||
}
|
||||
for client := range room {
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
// Slow client buffer full
|
||||
|
||||
if room, ok := h.rooms[targetRoom]; ok {
|
||||
for client := range room {
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
// Slow client buffer full
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, spaceID, userID string) {
|
||||
func (h *Hub) BroadcastListEvent(listID, event string, payload interface{}) {
|
||||
h.Broadcast("", listID, event, payload)
|
||||
}
|
||||
|
||||
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, roomID, userID string) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("[ws] upgrade error: %v", err)
|
||||
@@ -333,7 +339,7 @@ func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, spaceID, userID st
|
||||
client := &Client{
|
||||
hub: h,
|
||||
conn: conn,
|
||||
spaceID: spaceID,
|
||||
roomID: roomID,
|
||||
userID: userID,
|
||||
send: make(chan []byte, 128),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user