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
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
This commit is contained in:
1 parent
1776e87029
commit
6c1898d489
5 files changed
+225
-13
No files matched your search
@@ -131,7 +131,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
created.sharedWith?.some(
|
||||
(s) => s.userId === currentUser.id || s.targetId === currentUser.id
|
||||
) || false;
|
||||
if (!isOwner && !isShared) {
|
||||
const isFolderShared = Boolean(created.sharedFolderId || created.isSharedFolder);
|
||||
if (!isOwner && !isShared && !isFolderShared) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -146,6 +147,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
} else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') {
|
||||
api.getFolders().then(setFolders).catch(() => {});
|
||||
api.getSharedFolders().then(setSharedFolders).catch(() => {});
|
||||
api.getDocuments().then(setDocs).catch(() => {});
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
@@ -403,6 +405,24 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleShareFolderClick = useCallback(
|
||||
async (folderPath: string, existingSf?: SharedFolder) => {
|
||||
if (existingSf) {
|
||||
openFolderShareModal(existingSf);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const clean = folderPath.trim().replace(/^\/+|\/+$/g, '');
|
||||
const sf = await api.createSharedFolder({ name: clean, mountPath: clean });
|
||||
setSharedFolders((prev) => [...prev.filter((f) => f.id !== sf.id), sf]);
|
||||
openFolderShareModal(sf);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[openFolderShareModal]
|
||||
);
|
||||
|
||||
const handleToggleRollupTodo = async (doc: Document, blockId: string, currentChecked: boolean) => {
|
||||
triggerHaptic(10);
|
||||
const updatedBlocks = (doc.blocks || []).map((b) =>
|
||||
@@ -599,8 +619,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
{/* Scrollable Navigation Tree */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-3">
|
||||
{/* Drop target for moving docs to root */}
|
||||
{draggingDocId && (
|
||||
{/* Drop target for moving docs or folders to root */}
|
||||
{(draggingDocId || draggingFolderPath) && (
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -727,16 +747,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
{sharedFolderObj && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openFolderShareModal(sharedFolderObj)}
|
||||
className="p-1 rounded-btn hover:bg-canvas text-accent"
|
||||
title="Ordner teilen und Berechtigungen"
|
||||
>
|
||||
<Users className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleShareFolderClick(folderPath, sharedFolderObj)}
|
||||
className={`p-1 rounded-btn hover:bg-canvas ${
|
||||
sharedFolderObj ? 'text-accent' : 'text-faint hover:text-accent'
|
||||
}`}
|
||||
title="Ordner teilen und Berechtigungen"
|
||||
>
|
||||
<Users className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteFolder(folderPath)}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user