feat(workspace): decouple folder sharing with independent mount points & real-time sync
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s

This commit is contained in:
sandrodach committed 2026-10-26 22:53:51 +01:00
1 parent 1776e87029
commit 6c1898d489
5 files changed
+225 -13

No files matched your search

+10
View File
@@ -1204,6 +1204,11 @@ func (s *Server) handleCreateFolderShare(w http.ResponseWriter, r *http.Request)
return
}
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", share)
if docIDs, err := s.store.GetSharedFolderDocIDs(folderID); err == nil {
for _, docID := range docIDs {
s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
}
}
writeJSON(w, http.StatusCreated, share)
}
@@ -1211,11 +1216,16 @@ func (s *Server) handleDeleteFolderShare(w http.ResponseWriter, r *http.Request)
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
shareID := chi.URLParam(r, "shareId")
docIDs, _ := s.store.GetSharedFolderDocIDs(folderID)
if err := s.store.DeleteFolderShare(userID, folderID, shareID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID, "shareId": shareID})
for _, docID := range docIDs {
s.wsHub.BroadcastToUser(shareID, "DOC_DELETED", map[string]string{"id": docID})
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
+162
View File
@@ -989,5 +989,167 @@ func TestSSEGlobalEndpoint(t *testing.T) {
}
}
func TestDecoupledFolderSharingAPI(t *testing.T) {
srv, store, tokenA := setupTestServer(t)
router := srv.Router()
userB, err := store.CreateUser("user_b@example.com", "hash", "User B", "member")
if err != nil {
t.Fatalf("failed to create user B: %v", err)
}
tokenB, err := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName)
if err != nil {
t.Fatalf("failed to generate token B: %v", err)
}
// 1. User A creates a shared folder "TeamProjekt"
shPayload := map[string]string{"name": "TeamProjekt", "mountPath": "TeamProjekt"}
b, _ := json.Marshal(shPayload)
req := httptest.NewRequest(http.MethodPost, "/api/v1/workspace/folders/shared", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+tokenA)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create shared folder failed: %s", rec.Body.String())
}
var shFolder domain.SharedFolder
_ = json.Unmarshal(rec.Body.Bytes(), &shFolder)
// 2. User A creates a document inside "TeamProjekt"
docPayload := map[string]interface{}{
"title": "Spezifikation",
"folderPath": "TeamProjekt",
"blocks": []domain.DocumentBlock{
{ID: "blk_1", Type: "text", Content: "Projekt Details", Level: 0, Position: 1000},
},
}
b, _ = json.Marshal(docPayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/documents", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+tokenA)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create doc failed: %s", rec.Body.String())
}
var createdDoc domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &createdDoc)
// 3. User A shares the folder with User B (write permission)
sharePayload := map[string]interface{}{
"userId": userB.ID,
"targetType": "user",
"permission": "write",
}
b, _ = json.Marshal(sharePayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/workspace/folders/shared/"+shFolder.ID+"/shares", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+tokenA)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("share folder failed: %s", rec.Body.String())
}
// 4. User B gets their documents list -> must see the document in "TeamProjekt"
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents", nil)
req.Header.Set("Authorization", "Bearer "+tokenB)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("get docs userB failed: %s", rec.Body.String())
}
var docsB []domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &docsB)
var foundDocB *domain.Document
for i := range docsB {
if docsB[i].ID == createdDoc.ID {
foundDocB = &docsB[i]
break
}
}
if foundDocB == nil {
t.Fatalf("User B did not see shared document!")
}
if foundDocB.FolderPath != "TeamProjekt" {
t.Fatalf("expected User B initial folderPath 'TeamProjekt', got %q", foundDocB.FolderPath)
}
// 5. User B moves their mount point to "MeineOrdner/TeamProjekt"
movePayload := map[string]string{
"sourcePath": "TeamProjekt",
"targetParentPath": "MeineOrdner",
}
b, _ = json.Marshal(movePayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/folders/move", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+tokenB)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User B move folder failed: %s", rec.Body.String())
}
// 6. DECOUPLED CHECK:
// User B's document path must now be "MeineOrdner/TeamProjekt"
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
req.Header.Set("Authorization", "Bearer "+tokenB)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User B get doc failed: %s", rec.Body.String())
}
var docBAfterMove domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &docBAfterMove)
if docBAfterMove.FolderPath != "MeineOrdner/TeamProjekt" {
t.Fatalf("expected User B folderPath 'MeineOrdner/TeamProjekt', got %q", docBAfterMove.FolderPath)
}
// User A's document path must STILL be "TeamProjekt" (completely decoupled!)
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
req.Header.Set("Authorization", "Bearer "+tokenA)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User A get doc failed: %s", rec.Body.String())
}
var docAAfterMove domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &docAAfterMove)
if docAAfterMove.FolderPath != "TeamProjekt" {
t.Fatalf("User A's folder hierarchy was altered! Expected 'TeamProjekt', got %q", docAAfterMove.FolderPath)
}
// 7. Synchronous contents check:
// User B moves the document into a subfolder: "MeineOrdner/TeamProjekt/Archiv"
moveDocPayload := map[string]string{
"folderPath": "MeineOrdner/TeamProjekt/Archiv",
}
b, _ = json.Marshal(moveDocPayload)
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+createdDoc.ID+"/move", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+tokenB)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User B move doc into subfolder failed: %s", rec.Body.String())
}
// User A must now see the document synchronized at "TeamProjekt/Archiv"!
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
req.Header.Set("Authorization", "Bearer "+tokenA)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User A get doc failed: %s", rec.Body.String())
}
var docASynced domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &docASynced)
if docASynced.FolderPath != "TeamProjekt/Archiv" {
t.Fatalf("expected User A to see subfolder sync at 'TeamProjekt/Archiv', got %q", docASynced.FolderPath)
}
}
+18
View File
@@ -3426,6 +3426,24 @@ func (s *Store) DeleteSharedFolder(currentUserID, folderID string) error {
return err
}
// GetSharedFolderDocIDs returns all non-archived document IDs belonging to a shared folder.
func (s *Store) GetSharedFolderDocIDs(folderID string) ([]string, error) {
rows, err := s.db.Query(`SELECT id FROM documents WHERE shared_folder_id = ? AND is_archived = 0`, folderID)
if err != nil {
return nil, err
}
defer rows.Close()
var docIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil && id != "" {
docIDs = append(docIDs, id)
}
}
return docIDs, nil
}
func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) {
shares := make([]domain.DocShare, 0)
+2
View File
@@ -501,6 +501,8 @@ func (h *Hub) BroadcastTaskEvent(userID, event string, payload interface{}) {
func (h *Hub) BroadcastDocEvent(docID, event string, payload interface{}) {
if docID != "" {
h.BroadcastRoom("doc:"+docID, event, payload, nil)
} else {
h.BroadcastToAll(event, payload)
}
}