feat: complete workspace, triage, outliner and sharing overhaul (tasks 1-26)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s

This commit is contained in:
sandrodach committed 2026-10-27 19:46:46 +01:00
1 parent df2f2bac67
commit d39d1d1122
17 files changed
+1173 -501

No files matched your search

+54
View File
@@ -135,6 +135,7 @@ func (s *Server) Router() http.Handler {
r.Patch("/api/v1/documents/{id}/blocks", s.handlePatchDocumentBlocks)
r.Put("/api/v1/documents/{id}/move", s.handleMoveDocument)
r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument)
r.Post("/api/v1/documents/{id}/blocks/{blockId}/enhanced-task", s.handleCreateEnhancedTask)
// Document Sharing & Collaboration
r.Get("/api/v1/documents/{id}/shares", s.handleGetDocShares)
@@ -151,6 +152,7 @@ func (s *Server) Router() http.Handler {
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.Put("/api/v1/lists/{id}/items/{itemId}", s.handleUpdateListItem)
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem)
r.Post("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
r.Put("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
@@ -828,6 +830,9 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.IsCompleted != nil {
s.store.SyncDocBlockAndTask(userID, thoughtID, *req.IsCompleted)
}
s.wsHub.BroadcastToAll("TASK_UPDATED", updated)
writeJSON(w, http.StatusOK, updated)
}
@@ -835,6 +840,7 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
s.store.UnlinkTaskFromDocBlock(userID, thoughtID)
if err := s.store.DeleteThought(userID, thoughtID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -997,6 +1003,30 @@ func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleCreateEnhancedTask(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
blockID := chi.URLParam(r, "blockId")
var req struct {
Content string `json:"content"`
Checked bool `json:"checked"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
thought, err := s.store.CreateTaskFromDocBlock(userID, docID, blockID, req.Content, req.Checked)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(userID, "TASK_CREATED", thought)
writeJSON(w, http.StatusCreated, thought)
}
// --- WORKSPACE FOLDERS ---
func (s *Server) handleGetFolders(w http.ResponseWriter, r *http.Request) {
@@ -1574,6 +1604,30 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, item)
}
func (s *Server) handleUpdateListItem(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
itemID := chi.URLParam(r, "itemId")
var req struct {
Content *string `json:"content"`
Category *string `json:"category"`
Quantity *string `json:"quantity"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
item, err := s.store.UpdateListItem(itemID, req.Content, req.Category, req.Quantity)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "ITEM_UPDATED", item)
writeJSON(w, http.StatusOK, item)
}
func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
itemID := chi.URLParam(r, "itemId")
+24 -10
View File
@@ -1034,7 +1034,7 @@ func TestDecoupledFolderSharingAPI(t *testing.T) {
}
// 7. Synchronous contents check:
// User B moves the document into a subfolder: "MeineOrdner/TeamProjekt/Archiv"
// User B (read-only recipient) cannot move documents within the shared folder:
moveDocPayload := map[string]string{
"folderPath": "MeineOrdner/TeamProjekt/Archiv",
}
@@ -1044,22 +1044,36 @@ func TestDecoupledFolderSharingAPI(t *testing.T) {
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())
if rec.Code != http.StatusBadRequest {
t.Fatalf("Expected User B move doc in read-only shared folder to fail, got %d", rec.Code)
}
// User A must now see the document synchronized at "TeamProjekt/Archiv"!
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
// User A (the owner) moves the document into a subfolder: "TeamProjekt/Archiv"
moveDocPayload = map[string]string{
"folderPath": "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 "+tokenA)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User A get doc failed: %s", rec.Body.String())
t.Fatalf("User A move doc into subfolder 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)
// User B must now see the document synchronized at "MeineOrdner/TeamProjekt/Archiv"!
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 docBSynced domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &docBSynced)
if docBSynced.FolderPath != "MeineOrdner/TeamProjekt/Archiv" {
t.Fatalf("expected User B to see subfolder sync at 'MeineOrdner/TeamProjekt/Archiv', got %q", docBSynced.FolderPath)
}
}
+4 -1
View File
@@ -148,8 +148,10 @@ type Thought struct {
Source string `json:"source"` // manual | voice | webhook
Status ThoughtStatus `json:"status"`
IsCompleted bool `json:"isCompleted"`
ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item
ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item | document_block
ConvertedRefID string `json:"convertedRefId,omitempty"` // referenced entity ID
DocID string `json:"docId,omitempty"`
BlockID string `json:"blockId,omitempty"`
DueDate string `json:"dueDate,omitempty"`
TaskListID string `json:"taskListId,omitempty"`
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
@@ -270,6 +272,7 @@ type DocumentBlock struct {
Level int `json:"level"`
Position float64 `json:"position"`
Collapsed bool `json:"collapsed,omitempty"`
TaskID string `json:"taskId,omitempty"`
}
func CompileBlocksToMarkdown(blocks []DocumentBlock) string {
+201 -17
View File
@@ -414,9 +414,10 @@ func (s *Store) migrate() error {
// 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`)
// Ensure all existing document shares are strictly read-only
// Ensure all existing document & folder shares are strictly read-only
_, _ = s.db.Exec(`UPDATE doc_shares SET permission = 'read' WHERE permission != 'read'`)
_, _ = s.db.Exec(`UPDATE doc_team_shares SET permission = 'read' WHERE permission != 'read'`)
_, _ = s.db.Exec(`UPDATE workspace_folder_shares SET permission = 'read' WHERE permission != 'read'`)
// Default system settings (public registration disabled by default; invite or admin creation required)
_, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES
@@ -911,6 +912,30 @@ func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueD
}
tags := domain.ExtractTags(content)
if strings.EqualFold(source, "voice") {
hasDiktat := false
for _, tag := range tags {
if strings.EqualFold(tag, "diktat") {
hasDiktat = true
break
}
}
if !hasDiktat {
tags = append(tags, "diktat")
}
}
if strings.EqualFold(source, "webhook") {
hasWebhook := false
for _, tag := range tags {
if strings.EqualFold(tag, "webhook") {
hasWebhook = true
break
}
}
if !hasWebhook {
tags = append(tags, "webhook")
}
}
tagsJSON, _ := json.Marshal(tags)
t := &domain.Thought{
@@ -968,6 +993,11 @@ func scanThought(rows interface {
}
t.Tags = []string{}
_ = json.Unmarshal([]byte(tagsStr), &t.Tags)
if t.ConvertedType == "document_block" && strings.Contains(t.ConvertedRefID, ":") {
parts := strings.SplitN(t.ConvertedRefID, ":", 2)
t.DocID = parts[0]
t.BlockID = parts[1]
}
t.Subtasks = []domain.SubTask{}
if subtasksStr != "" && subtasksStr != "null" {
_ = json.Unmarshal([]byte(subtasksStr), &t.Subtasks)
@@ -1137,23 +1167,23 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
title = title[:72] + "…"
}
blocks := make([]domain.DocumentBlock, 0)
formattedDate := domain.FormatGermanDateTime(thought.CreatedAt)
calloutText := "Ursprünglicher Gedanke aus deiner Inbox"
if formattedDate != "" {
calloutText += " vom " + formattedDate
}
// 17. Wenn ich eine Aufgabe zu einer Notiz mache, muss die erste Zeile das "To-Do" noch mal als "To-Do" enthalten, auch wenn es schon in der Überschrift steht. Wenn es vorher schon abgehakt war, dann den Zustand natürlich übernehmen.
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
Type: "callout",
Content: calloutText,
Type: "todo",
Content: thought.Content,
Checked: thought.IsCompleted,
Level: 0,
})
// 17a. Die anderen Details aus der Aufgabe sollen natürlich ebenfalls übernommen werden (Notizen, Subtasks, Fälligkeit/Priorität).
if strings.TrimSpace(thought.Notes) != "" {
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
Type: "bullet",
Content: strings.TrimSpace(thought.Notes),
Level: 0,
Level: 1,
})
}
if len(thought.Subtasks) > 0 {
@@ -1163,10 +1193,25 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
Type: "todo",
Content: st.Title,
Checked: st.IsCompleted,
Level: 0,
Level: 1,
})
}
}
var metaItems []string
if thought.DueDate != "" {
metaItems = append(metaItems, "Fällig: "+thought.DueDate)
}
if thought.Priority != "" && thought.Priority != "normal" {
metaItems = append(metaItems, "Priorität: "+thought.Priority)
}
if len(metaItems) > 0 {
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
Type: "bullet",
Content: strings.Join(metaItems, " • "),
Level: 1,
})
}
if len(blocks) == 1 {
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
@@ -2354,6 +2399,11 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
}
if blocks != nil {
_ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks)
for _, b := range doc.Blocks {
if b.TaskID != "" {
s.SyncTaskFromBlockCheck(doc.UserID, b.TaskID, b.Checked)
}
}
}
return doc, nil
}
@@ -2888,8 +2938,8 @@ func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Do
if err != nil {
return nil, err
}
if !shFolder.IsOwner && shFolder.Permission != "write" {
return nil, fmt.Errorf("keine Schreibberechtigung für diesen geteilten Ordner")
if !shFolder.IsOwner {
return nil, fmt.Errorf("nur der Eigentümer dieses geteilten Ordners kann Dateien dorthin verschieben")
}
// Update document to belong to targetSharedFolderID
@@ -3338,8 +3388,8 @@ func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIde
if err != nil {
return nil, err
}
if !f.IsOwner && f.Permission != "write" {
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Ordner-Freigaben verwalten")
if !f.IsOwner {
return nil, fmt.Errorf("nur der Eigentümer kann Ordner-Freigaben verwalten")
}
cleanType := strings.TrimSpace(targetType)
@@ -3352,9 +3402,8 @@ func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIde
}
}
if permission != "read" && permission != "write" {
permission = "read"
}
// Geteilte Ordner können ausschließlich mit Leserberechtigung geteilt werden (wie Dokumente)
permission = "read"
var targetID, targetName, email, avatarURL string
if cleanType == "team" {
@@ -4090,6 +4139,141 @@ func (s *Store) ToggleListItem(itemID string, isCompleted bool, userID, userName
return &it, nil
}
func (s *Store) UpdateListItem(itemID string, content *string, category *string, quantity *string) (*domain.ListItem, error) {
now := domain.NowISO()
var it domain.ListItem
var dInt int
err := s.db.QueryRow(
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
FROM list_items WHERE id = ?`,
itemID,
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt)
if err != nil {
return nil, err
}
it.IsCompleted = dInt != 0
if content != nil {
it.Content = *content
}
if category != nil {
it.Category = *category
}
if quantity != nil {
it.Quantity = *quantity
}
_, err = s.db.Exec(`UPDATE list_items SET content = ?, category = ?, quantity = ? WHERE id = ?`, it.Content, it.Category, it.Quantity, itemID)
if err != nil {
return nil, err
}
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID)
return &it, nil
}
func (s *Store) CreateTaskFromDocBlock(userID, docID, blockID, content string, checked bool) (*domain.Thought, error) {
doc, err := s.GetDocumentByID(userID, docID)
if err != nil {
return nil, err
}
t, err := s.CreateThoughtWithDetails(userID, content, "normal", "manual", "", "", "todo", "")
if err != nil {
return nil, err
}
t.ConvertedType = "document_block"
t.ConvertedRefID = docID + ":" + blockID
t.DocID = docID
t.BlockID = blockID
t.IsCompleted = checked
doneInt := 0
if checked {
doneInt = 1
t.KanbanStatus = "done"
}
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = 'document_block', converted_ref_id = ?, is_completed = ?, kanban_status = ? WHERE id = ?`,
t.ConvertedRefID, doneInt, t.KanbanStatus, t.ID)
var updatedBlocks []domain.DocumentBlock
found := false
for _, b := range doc.Blocks {
if b.ID == blockID {
b.TaskID = t.ID
b.Checked = checked
found = true
}
updatedBlocks = append(updatedBlocks, b)
}
if found {
_, _ = s.UpdateDocument(userID, docID, nil, nil, nil, &updatedBlocks, nil, nil)
}
return t, nil
}
func (s *Store) SyncDocBlockAndTask(userID, taskID string, isCompleted bool) {
t, err := s.GetThoughtByID(userID, taskID)
if err != nil || t == nil || t.ConvertedType != "document_block" || !strings.Contains(t.ConvertedRefID, ":") {
return
}
parts := strings.SplitN(t.ConvertedRefID, ":", 2)
docID, blockID := parts[0], parts[1]
doc, err := s.GetDocumentByID(userID, docID)
if err != nil || doc == nil {
return
}
var updatedBlocks []domain.DocumentBlock
found := false
for _, b := range doc.Blocks {
if b.ID == blockID {
if b.Checked != isCompleted {
b.Checked = isCompleted
found = true
}
}
updatedBlocks = append(updatedBlocks, b)
}
if found {
_, _ = s.UpdateDocument(userID, docID, nil, nil, nil, &updatedBlocks, nil, nil)
}
}
func (s *Store) UnlinkTaskFromDocBlock(userID, taskID string) {
t, err := s.GetThoughtByID(userID, taskID)
if err != nil || t == nil || t.ConvertedType != "document_block" || !strings.Contains(t.ConvertedRefID, ":") {
return
}
parts := strings.SplitN(t.ConvertedRefID, ":", 2)
docID, blockID := parts[0], parts[1]
doc, err := s.GetDocumentByID(userID, docID)
if err != nil || doc == nil {
return
}
var updatedBlocks []domain.DocumentBlock
found := false
for _, b := range doc.Blocks {
if b.ID == blockID {
b.TaskID = ""
found = true
}
updatedBlocks = append(updatedBlocks, b)
}
if found {
_, _ = s.UpdateDocument(userID, docID, nil, nil, nil, &updatedBlocks, nil, nil)
}
}
func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) {
if taskID == "" {
return
}
doneInt := 0
kanban := "todo"
if checked {
doneInt = 1
kanban = "done"
}
_, _ = s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ? WHERE id = ? AND user_id = ?`, doneInt, kanban, taskID, userID)
}
func (s *Store) DeleteListItem(itemID string) (string, error) {
var listID string
_ = s.db.QueryRow(`SELECT list_id FROM list_items WHERE id = ?`, itemID).Scan(&listID)