Dev #7
No files matched your search
@@ -131,7 +131,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
created.sharedWith?.some(
|
created.sharedWith?.some(
|
||||||
(s) => s.userId === currentUser.id || s.targetId === currentUser.id
|
(s) => s.userId === currentUser.id || s.targetId === currentUser.id
|
||||||
) || false;
|
) || false;
|
||||||
if (!isOwner && !isShared) {
|
const isFolderShared = Boolean(created.sharedFolderId || created.isSharedFolder);
|
||||||
|
if (!isOwner && !isShared && !isFolderShared) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,6 +147,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
} else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') {
|
} else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') {
|
||||||
api.getFolders().then(setFolders).catch(() => {});
|
api.getFolders().then(setFolders).catch(() => {});
|
||||||
api.getSharedFolders().then(setSharedFolders).catch(() => {});
|
api.getSharedFolders().then(setSharedFolders).catch(() => {});
|
||||||
|
api.getDocuments().then(setDocs).catch(() => {});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return unsub;
|
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) => {
|
const handleToggleRollupTodo = async (doc: Document, blockId: string, currentChecked: boolean) => {
|
||||||
triggerHaptic(10);
|
triggerHaptic(10);
|
||||||
const updatedBlocks = (doc.blocks || []).map((b) =>
|
const updatedBlocks = (doc.blocks || []).map((b) =>
|
||||||
@@ -599,8 +619,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
{/* Scrollable Navigation Tree */}
|
{/* Scrollable Navigation Tree */}
|
||||||
<div className="flex-1 overflow-y-auto p-2 space-y-3">
|
<div className="flex-1 overflow-y-auto p-2 space-y-3">
|
||||||
{/* Drop target for moving docs to root */}
|
{/* Drop target for moving docs or folders to root */}
|
||||||
{draggingDocId && (
|
{(draggingDocId || draggingFolderPath) && (
|
||||||
<div
|
<div
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -727,16 +747,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
>
|
>
|
||||||
<Plus className="w-3 h-3" />
|
<Plus className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
{sharedFolderObj && (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => handleShareFolderClick(folderPath, sharedFolderObj)}
|
||||||
onClick={() => openFolderShareModal(sharedFolderObj)}
|
className={`p-1 rounded-btn hover:bg-canvas ${
|
||||||
className="p-1 rounded-btn hover:bg-canvas text-accent"
|
sharedFolderObj ? 'text-accent' : 'text-faint hover:text-accent'
|
||||||
title="Ordner teilen und Berechtigungen"
|
}`}
|
||||||
>
|
title="Ordner teilen und Berechtigungen"
|
||||||
<Users className="w-3 h-3" />
|
>
|
||||||
</button>
|
<Users className="w-3 h-3" />
|
||||||
)}
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDeleteFolder(folderPath)}
|
onClick={() => handleDeleteFolder(folderPath)}
|
||||||
|
|||||||
@@ -1204,6 +1204,11 @@ func (s *Server) handleCreateFolderShare(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", share)
|
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)
|
writeJSON(w, http.StatusCreated, share)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1211,11 +1216,16 @@ func (s *Server) handleDeleteFolderShare(w http.ResponseWriter, r *http.Request)
|
|||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
folderID := chi.URLParam(r, "id")
|
folderID := chi.URLParam(r, "id")
|
||||||
shareID := chi.URLParam(r, "shareId")
|
shareID := chi.URLParam(r, "shareId")
|
||||||
|
docIDs, _ := s.store.GetSharedFolderDocIDs(folderID)
|
||||||
if err := s.store.DeleteFolderShare(userID, folderID, shareID); err != nil {
|
if err := s.store.DeleteFolderShare(userID, folderID, shareID); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID, "shareId": shareID})
|
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})
|
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
|
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) {
|
func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) {
|
||||||
shares := make([]domain.DocShare, 0)
|
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{}) {
|
func (h *Hub) BroadcastDocEvent(docID, event string, payload interface{}) {
|
||||||
if docID != "" {
|
if docID != "" {
|
||||||
h.BroadcastRoom("doc:"+docID, event, payload, nil)
|
h.BroadcastRoom("doc:"+docID, event, payload, nil)
|
||||||
|
} else {
|
||||||
|
h.BroadcastToAll(event, payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user