diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 0f1a057..6ec634b 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -16,8 +16,10 @@ import { Share2, Users, CheckSquare, + User as UserIcon, + Lock, } from 'lucide-react'; -import { TaskList, Thought, Space, SubTask } from '../../lib/types'; +import { TaskList, Thought, SubTask, Team, User, TaskListShare } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; const getInitials = (name?: string): string => { @@ -45,7 +47,8 @@ export const TasksView: React.FC = ({ const [tasks, setTasks] = useState([]); const [taskLists, setTaskLists] = useState([]); const [selectedListId, setSelectedListId] = useState('all'); - const [spaces, setSpaces] = useState([]); + const [teams, setTeams] = useState([]); + const [collaborators, setCollaborators] = useState([]); const [discarded, setDiscarded] = useState([]); const [loading, setLoading] = useState(true); @@ -57,12 +60,14 @@ export const TasksView: React.FC = ({ const [showNewListModal, setShowNewListModal] = useState(false); const [newListTitle, setNewListTitle] = useState(''); const [newListIcon, setNewListIcon] = useState('📋'); - const [newListSpaceId, setNewListSpaceId] = useState(''); + const [newListShareTarget, setNewListShareTarget] = useState(''); // Share TaskList modal state const [showShareListModal, setShowShareListModal] = useState(false); const [sharingTaskList, setSharingTaskList] = useState(null); - const [shareTargetSpaceId, setShareTargetSpaceId] = useState(''); + const [addShareType, setAddShareType] = useState<'team' | 'user'>('team'); + const [selectedShareTargetId, setSelectedShareTargetId] = useState(''); + const [shareLoading, setShareLoading] = useState(false); // Active notes inline edit ID const [activeNotesTaskId, setActiveNotesTaskId] = useState(null); @@ -75,15 +80,17 @@ export const TasksView: React.FC = ({ const loadData = async () => { setLoading(true); try { - const [allTasks, allLists, allSpaces, allDiscarded] = await Promise.all([ + const [allTasks, allLists, allTeams, allCollabs, allDiscarded] = await Promise.all([ api.getTasks(selectedListId === 'all' ? undefined : selectedListId).catch(() => []), api.getTaskLists().catch(() => []), - api.getSpaces().catch(() => []), + api.getUserTeams().catch(() => []), + api.getCollaborators().catch(() => []), api.getThoughts('discarded').catch(() => []), ]); setTasks(Array.isArray(allTasks) ? allTasks : []); setTaskLists(Array.isArray(allLists) ? allLists : []); - setSpaces(Array.isArray(allSpaces) ? allSpaces : []); + setTeams(Array.isArray(allTeams) ? allTeams : []); + setCollaborators(Array.isArray(allCollabs) ? allCollabs : []); setDiscarded(Array.isArray(allDiscarded) ? allDiscarded : []); } catch { // Prevent unhandled promise rejection from crashing view @@ -424,15 +431,27 @@ export const TasksView: React.FC = ({ const title = newListTitle.trim(); if (!title) return; try { + let teamId: string | undefined = undefined; + let userId: string | undefined = undefined; + if (newListShareTarget.startsWith('team:')) { + teamId = newListShareTarget.replace('team:', ''); + } else if (newListShareTarget.startsWith('user:')) { + userId = newListShareTarget.replace('user:', ''); + } const created = await api.createTaskList({ title, icon: newListIcon, - spaceId: newListSpaceId, + teamId: teamId, }); - setTaskLists((prev) => [...prev, created]); + if (userId && created?.id) { + await api.addTaskListShare(created.id, { targetType: 'user', targetId: userId }); + } + const freshLists = await api.getTaskLists(); + setTaskLists(freshLists); setSelectedListId(created.id); setShowNewListModal(false); setNewListTitle(''); + setNewListShareTarget(''); } catch (err: any) { alert(err.message || 'Fehler beim Erstellen der Aufgabenliste'); } @@ -456,25 +475,47 @@ export const TasksView: React.FC = ({ const handleOpenShareModal = (tl: TaskList) => { setSharingTaskList(tl); - setShareTargetSpaceId(tl.spaceId || ''); + setSelectedShareTargetId(''); setShowShareListModal(true); }; - const handleSaveShareList = async () => { - if (!sharingTaskList) return; + const handleAddShare = async () => { + if (!sharingTaskList || !selectedShareTargetId) return; + setShareLoading(true); triggerHaptic(15); try { - const updated = await api.updateTaskList(sharingTaskList.id, { - spaceId: shareTargetSpaceId, + await api.addTaskListShare(sharingTaskList.id, { + targetType: addShareType, + targetId: selectedShareTargetId, }); - setTaskLists((prev) => - prev.map((l) => (l.id === updated.id ? { ...l, ...updated } : l)) - ); - setShowShareListModal(false); - setSharingTaskList(null); + const freshLists = await api.getTaskLists(); + setTaskLists(freshLists); + const updated = freshLists.find((l) => l.id === sharingTaskList.id); + if (updated) setSharingTaskList(updated); + setSelectedShareTargetId(''); await loadData(); } catch (err: any) { - alert(err.message || 'Fehler beim Speichern der Freigabe'); + alert(err.message || 'Fehler beim Hinzufügen der Freigabe'); + } finally { + setShareLoading(false); + } + }; + + const handleRemoveShare = async (shareId: string) => { + if (!sharingTaskList) return; + setShareLoading(true); + triggerHaptic(15); + try { + await api.removeTaskListShare(sharingTaskList.id, shareId); + const freshLists = await api.getTaskLists(); + setTaskLists(freshLists); + const updated = freshLists.find((l) => l.id === sharingTaskList.id); + if (updated) setSharingTaskList(updated); + await loadData(); + } catch (err: any) { + alert(err.message || 'Fehler beim Entfernen der Freigabe'); + } finally { + setShareLoading(false); } }; @@ -491,7 +532,8 @@ export const TasksView: React.FC = ({ return unique; }, [tasks]); const safeTaskLists = Array.isArray(taskLists) ? taskLists : []; - const safeSpaces = Array.isArray(spaces) ? spaces : []; + const safeTeams = Array.isArray(teams) ? teams : []; + const safeCollaborators = Array.isArray(collaborators) ? collaborators : []; const safeDiscarded = Array.isArray(discarded) ? discarded : []; const openTasks = safeTasks.filter((t) => !t.isCompleted); @@ -547,8 +589,8 @@ export const TasksView: React.FC = ({ ))} - ) : tl.spaceId ? ( - + ) : (tl.teamId || (tl.shares && tl.shares.length > 0)) ? ( + ) : null} @@ -594,7 +636,7 @@ export const TasksView: React.FC = ({
handleOpenShareModal(currentSelectedList)} className="flex items-center -space-x-1.5 cursor-pointer hover:opacity-90 transition-opacity pl-0.5" - title={`Geteilt mit ${currentSelectedList.members.length} Mitgliedern (${currentSelectedList.spaceName || 'Team'}). Klick zum Verwalten.`} + title={`Geteilt mit ${currentSelectedList.members.length} Mitgliedern (${currentSelectedList.teamName || 'Team'}). Klick zum Verwalten.`} > {currentSelectedList.members.slice(0, 3).map((m) => (
= ({
- {safeSpaces.length > 0 && ( -
- - -
- )} +
+ + +
-
+
+ {/* Active Shares */}
-
- {/* Members preview */} - {(() => { - const selectedSpace = safeSpaces.find((s) => s.id === shareTargetSpaceId); - const members = selectedSpace?.members || []; - if (members.length > 0) { - return ( -
-

- Personen mit Zugriff ({members.length}): -

-
- {members.map((m) => ( -
-
-
- {getInitials(m.displayName || m.email)} -
-
-

{m.displayName || m.email}

-

{m.email}

-
-
- - {m.role === 'owner' ? 'Eigentümer' : m.role === 'editor' ? 'Bearbeiter' : 'Mitglied'} - + {/* Add New Share */} + {sharingTaskList.isOwner !== false && ( +
+ +
+ + +
+ +
+ {addShareType === 'team' ? ( + + ) : ( + + )} + +
+
+ )} + + {/* Persons with access summary */} + {sharingTaskList.members && sharingTaskList.members.length > 0 && ( +
+

+ Personen mit Zugriff ({sharingTaskList.members.length}): +

+
+ {sharingTaskList.members.map((m) => ( +
+
+
+ {getInitials(m.displayName || m.email)}
- ))} + + {m.displayName || m.email} + +
+ + {m.role === 'owner' ? 'Eigentümer' : m.role === 'editor' ? 'Bearbeiter' : 'Mitglied'} +
-
- ); - } else if (shareTargetSpaceId) { - return ( -

Keine Mitglieder in diesem Bereich.

- ); - } else { - return ( -
-

Diese Aufgabenliste ist privat.

-

- Wähle oben einen Familien- oder Team-Space aus, um die Liste und alle enthaltenen Aufgaben automatisch mit den Mitgliedern zu teilen. -

-
- ); - } - })()} + ))} +
+
+ )}
-
+
-
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 82df2e3..d0e554e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -15,6 +15,7 @@ import { SystemSettings, Team, TaskList, + TaskListShare, Thought, SubTask, TagDetails, @@ -336,13 +337,18 @@ export const api = { // --- TASK LISTS --- async getTaskLists(): Promise { const res = await request('/api/v1/task-lists'); - return ensureArray(res); + return ensureArray(res).map((tl) => ({ + ...tl, + members: ensureArray(tl.members), + shares: ensureArray(tl.shares), + })); }, async createTaskList(payload: { title: string; icon?: string; color?: string; + teamId?: string; spaceId?: string; }): Promise { return request('/api/v1/task-lists', { @@ -357,6 +363,7 @@ export const api = { title?: string; icon?: string; color?: string; + teamId?: string; spaceId?: string; } ): Promise { @@ -370,6 +377,25 @@ export const api = { await request(`/api/v1/task-lists/${id}`, { method: 'DELETE' }); }, + async getTaskListShares(id: string): Promise { + const res = await request(`/api/v1/task-lists/${id}/shares`); + return ensureArray(res); + }, + + async addTaskListShare( + id: string, + payload: { targetType: 'team' | 'user'; targetId: string } + ): Promise { + return request(`/api/v1/task-lists/${id}/shares`, { + method: 'POST', + body: JSON.stringify(payload), + }); + }, + + async removeTaskListShare(id: string, shareId: string): Promise { + await request(`/api/v1/task-lists/${id}/shares/${shareId}`, { method: 'DELETE' }); + }, + // --- WORKSPACE FOLDERS --- async getFolders(): Promise { const res = await request('/api/v1/folders'); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 1ab2c1f..dda2736 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -64,13 +64,34 @@ export interface Thought { triagedAt?: string; } +export interface TaskListShare { + id: string; + taskListId: string; + targetType: 'team' | 'user'; + targetId: string; + targetName?: string; + email?: string; + createdAt: string; +} + +export interface TaskListMember { + userId: string; + displayName: string; + email: string; + role: string; +} + export interface TaskList { id: string; userId: string; spaceId?: string; spaceName?: string; + teamId?: string; + teamName?: string; ownerName?: string; - members?: SpaceMember[]; + isOwner?: boolean; + members?: TaskListMember[]; + shares?: TaskListShare[]; title: string; icon: string; color: string; diff --git a/internal/api/server.go b/internal/api/server.go index 26702ee..307282f 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -89,11 +89,14 @@ func (s *Server) Router() http.Handler { r.Patch("/api/v1/triage/{id}", s.handleTriageAction) r.Get("/api/v1/tasks", s.handleGetTasks) - // Task Lists + // Task Lists & Shares r.Get("/api/v1/task-lists", s.handleGetTaskLists) r.Post("/api/v1/task-lists", s.handleCreateTaskList) r.Put("/api/v1/task-lists/{id}", s.handleUpdateTaskList) r.Delete("/api/v1/task-lists/{id}", s.handleDeleteTaskList) + r.Get("/api/v1/task-lists/{id}/shares", s.handleGetTaskListShares) + r.Post("/api/v1/task-lists/{id}/shares", s.handleCreateTaskListShare) + r.Delete("/api/v1/task-lists/{id}/shares/{shareId}", s.handleDeleteTaskListShare) // Workspace Documents & Folders r.Get("/api/v1/folders", s.handleGetFolders) @@ -698,7 +701,7 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - s.wsHub.BroadcastTaskEvent(updated.UserID, "TASK_UPDATED", updated) + s.wsHub.BroadcastToAll("TASK_UPDATED", updated) writeJSON(w, http.StatusOK, updated) } @@ -709,7 +712,7 @@ func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err.Error()) return } - s.wsHub.BroadcastTaskEvent(userID, "TASK_DELETED", map[string]string{"id": thoughtID}) + s.wsHub.BroadcastToAll("TASK_DELETED", map[string]string{"id": thoughtID}) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } @@ -1566,6 +1569,7 @@ func (s *Server) handleCreateTaskList(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) var req struct { SpaceID string `json:"spaceId"` + TeamID string `json:"teamId"` Title string `json:"title"` Icon string `json:"icon"` Color string `json:"color"` @@ -1574,7 +1578,7 @@ func (s *Server) handleCreateTaskList(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } - tl, err := s.store.CreateTaskList(userID, req.SpaceID, req.Title, req.Icon, req.Color) + tl, err := s.store.CreateTaskList(userID, req.SpaceID, req.TeamID, req.Title, req.Icon, req.Color) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return @@ -1589,17 +1593,19 @@ func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) { Title *string `json:"title"` Icon *string `json:"icon"` Color *string `json:"color"` + TeamID *string `json:"teamId"` SpaceID *string `json:"spaceId"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } - tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.SpaceID) + tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.TeamID, req.SpaceID) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } + s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", tl) writeJSON(w, http.StatusOK, tl) } @@ -1610,6 +1616,74 @@ func (s *Server) handleDeleteTaskList(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err.Error()) return } + s.wsHub.BroadcastToAll("TASK_LIST_DELETED", map[string]string{"id": listID}) + writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) +} + +func (s *Server) handleGetTaskListShares(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + listID := chi.URLParam(r, "id") + if _, err := s.store.GetTaskListByID(userID, listID); err != nil { + writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden oder kein Zugriff") + return + } + shares, err := s.store.GetTaskListShares(listID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, shares) +} + +func (s *Server) handleCreateTaskListShare(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + listID := chi.URLParam(r, "id") + tl, err := s.store.GetTaskListByID(userID, listID) + if err != nil || tl == nil { + writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden") + return + } + if tl.UserID != userID { + writeError(w, http.StatusForbidden, "Nur der Eigentümer kann die Liste freigeben") + return + } + var req struct { + TargetType string `json:"targetType"` // team | user + TargetID string `json:"targetId"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "Ungültiges JSON") + return + } + share, err := s.store.AddTaskListShare(listID, req.TargetType, req.TargetID, userID) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + updatedList, _ := s.store.GetTaskListByID(userID, listID) + s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", updatedList) + writeJSON(w, http.StatusCreated, share) +} + +func (s *Server) handleDeleteTaskListShare(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + listID := chi.URLParam(r, "id") + shareID := chi.URLParam(r, "shareId") + tl, err := s.store.GetTaskListByID(userID, listID) + if err != nil || tl == nil { + writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden") + return + } + if tl.UserID != userID { + writeError(w, http.StatusForbidden, "Nur der Eigentümer kann Freigaben entfernen") + return + } + if err := s.store.RemoveTaskListShare(listID, shareID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + updatedList, _ := s.store.GetTaskListByID(userID, listID) + s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", updatedList) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } diff --git a/internal/domain/models.go b/internal/domain/models.go index fc1ea09..8910918 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -158,19 +158,40 @@ type Thought struct { TriagedAt string `json:"triagedAt,omitempty"` } +type TaskListShare struct { + ID string `json:"id"` + TaskListID string `json:"taskListId"` + TargetType string `json:"targetType"` // team | user + TargetID string `json:"targetId"` + TargetName string `json:"targetName,omitempty"` + Email string `json:"email,omitempty"` + CreatedAt string `json:"createdAt"` +} + +type TaskListMember struct { + UserID string `json:"userId"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Role string `json:"role"` // owner | member +} + type TaskList struct { - ID string `json:"id"` - UserID string `json:"userId"` - SpaceID string `json:"spaceId,omitempty"` - SpaceName string `json:"spaceName,omitempty"` - OwnerName string `json:"ownerName,omitempty"` - Members []SpaceMember `json:"members,omitempty"` - Title string `json:"title"` - Icon string `json:"icon"` - Color string `json:"color"` - TasksCount int `json:"tasksCount,omitempty"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + ID string `json:"id"` + UserID string `json:"userId"` + SpaceID string `json:"spaceId,omitempty"` + SpaceName string `json:"spaceName,omitempty"` + TeamID string `json:"teamId,omitempty"` + TeamName string `json:"teamName,omitempty"` + OwnerName string `json:"ownerName,omitempty"` + IsOwner bool `json:"isOwner"` + Members []TaskListMember `json:"members,omitempty"` + Shares []TaskListShare `json:"shares,omitempty"` + Title string `json:"title"` + Icon string `json:"icon"` + Color string `json:"color"` + TasksCount int `json:"tasksCount,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } type Organization struct { diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 87f77b7..e2eb044 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -125,6 +125,7 @@ func (s *Store) migrate() error { id TEXT PRIMARY KEY, user_id TEXT NOT NULL, space_id TEXT DEFAULT '', + team_id TEXT DEFAULT '', title TEXT NOT NULL, icon TEXT NOT NULL DEFAULT '📋', color TEXT NOT NULL DEFAULT '#2563eb', @@ -321,6 +322,11 @@ func (s *Store) migrate() error { "CREATE TABLE IF NOT EXISTS doc_shares (id TEXT PRIMARY KEY, doc_id TEXT NOT NULL, user_id TEXT NOT NULL, permission TEXT NOT NULL CHECK(permission IN ('read', 'write')), created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(doc_id, user_id));", "CREATE INDEX IF NOT EXISTS idx_doc_shares_user ON doc_shares(user_id);", "CREATE INDEX IF NOT EXISTS idx_doc_shares_doc ON doc_shares(doc_id);", + + "ALTER TABLE task_lists ADD COLUMN team_id TEXT NOT NULL DEFAULT '';", + "CREATE TABLE IF NOT EXISTS task_list_shares (id TEXT PRIMARY KEY, task_list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(task_list_id, target_type, target_id));", + "CREATE INDEX IF NOT EXISTS idx_task_list_shares_target ON task_list_shares(target_type, target_id);", + "CREATE INDEX IF NOT EXISTS idx_task_list_shares_list ON task_list_shares(task_list_id);", } for _, q := range columnMigrations { _, _ = s.db.Exec(q) @@ -823,11 +829,23 @@ func scanThought(rows interface { return t, nil } +func (s *Store) accessibleTaskListIDsQuery() string { + return `SELECT id FROM task_lists + WHERE user_id = ? + OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + OR id IN ( + 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 (SELECT id FROM task_lists WHERE space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)))`, - thoughtID, userID, userID, + FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`, + thoughtID, userID, userID, userID, userID, userID, userID, ) return scanThought(row) } @@ -876,20 +894,20 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er rows, err = s.db.Query( `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 (user_id = ? OR task_list_id IN (SELECT id FROM task_lists WHERE space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))) + WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`)) 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, taskListID, + userID, userID, userID, userID, userID, userID, taskListID, ) } else { rows, err = s.db.Query( `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 (user_id = ? OR task_list_id IN (SELECT id FROM task_lists WHERE space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))) + 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, ) } if err != nil { @@ -1060,8 +1078,8 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string, } _, err = s.db.Exec( - `UPDATE thoughts SET content = ?, tags = ?, is_completed = ?, due_date = ?, priority = ?, task_list_id = ?, kanban_status = ?, notes = ?, subtasks = ? WHERE id = ? AND user_id = ?`, - thought.Content, string(tagsJSON), completedInt, thought.DueDate, thought.Priority, thought.TaskListID, thought.KanbanStatus, thought.Notes, string(subtasksJSON), thoughtID, userID, + `UPDATE thoughts SET content = ?, tags = ?, is_completed = ?, due_date = ?, priority = ?, task_list_id = ?, kanban_status = ?, notes = ?, subtasks = ? WHERE id = ?`, + thought.Content, string(tagsJSON), completedInt, thought.DueDate, thought.Priority, thought.TaskListID, thought.KanbanStatus, thought.Notes, string(subtasksJSON), thoughtID, ) if err != nil { return nil, err @@ -1070,7 +1088,11 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string, } func (s *Store) DeleteThought(userID, thoughtID string) error { - _, err := s.db.Exec(`DELETE FROM thoughts WHERE id = ? AND user_id = ?`, thoughtID, userID) + thought, err := s.GetThoughtByID(userID, thoughtID) + if err != nil { + return err + } + _, err = s.db.Exec(`DELETE FROM thoughts WHERE id = ?`, thought.ID) return err } @@ -2529,7 +2551,7 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) { // --- TASK LISTS --- -func (s *Store) CreateTaskList(userID, spaceID, title, icon, color string) (*domain.TaskList, error) { +func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color string) (*domain.TaskList, error) { if strings.TrimSpace(title) == "" { title = "Meine Aufgaben" } @@ -2544,29 +2566,181 @@ func (s *Store) CreateTaskList(userID, spaceID, title, icon, color string) (*dom ID: domain.NewID("tkl"), UserID: userID, SpaceID: spaceID, + TeamID: teamID, Title: strings.TrimSpace(title), Icon: icon, Color: color, CreatedAt: now, UpdatedAt: now, + IsOwner: true, } _, err := s.db.Exec( - `INSERT INTO task_lists (id, user_id, space_id, title, icon, color, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - tl.ID, tl.UserID, tl.SpaceID, tl.Title, tl.Icon, tl.Color, tl.CreatedAt, tl.UpdatedAt, + `INSERT INTO task_lists (id, user_id, space_id, team_id, title, icon, color, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + tl.ID, tl.UserID, tl.SpaceID, tl.TeamID, tl.Title, tl.Icon, tl.Color, tl.CreatedAt, tl.UpdatedAt, ) if err != nil { return nil, err } - return tl, nil + if teamID != "" { + _, _ = s.AddTaskListShare(tl.ID, "team", teamID, userID) + } + return s.GetTaskListByID(userID, tl.ID) +} + +func (s *Store) AddTaskListShare(taskListID, targetType, targetID, createdBy string) (*domain.TaskListShare, 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") + } + now := domain.NowISO() + share := &domain.TaskListShare{ + ID: domain.NewID("tls"), + TaskListID: taskListID, + TargetType: targetType, + TargetID: targetID, + CreatedAt: now, + } + _, err := s.db.Exec( + `INSERT INTO task_list_shares (id, task_list_id, target_type, target_id, created_at, created_by) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(task_list_id, target_type, target_id) DO NOTHING`, + share.ID, share.TaskListID, share.TargetType, share.TargetID, share.CreatedAt, createdBy, + ) + if err != nil { + return nil, err + } + if targetType == "team" { + _, _ = s.db.Exec(`UPDATE task_lists SET team_id = ? WHERE id = ? AND (team_id = '' OR team_id IS NULL)`, targetID, taskListID) + _ = 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) RemoveTaskListShare(taskListID, shareIDOrTargetID string) error { + var targetType, targetID string + _ = s.db.QueryRow(`SELECT target_type, target_id FROM task_list_shares WHERE task_list_id = ? AND (id = ? OR target_id = ?)`, taskListID, shareIDOrTargetID, shareIDOrTargetID).Scan(&targetType, &targetID) + + _, err := s.db.Exec( + `DELETE FROM task_list_shares WHERE task_list_id = ? AND (id = ? OR target_id = ?)`, + taskListID, shareIDOrTargetID, shareIDOrTargetID, + ) + if targetType == "team" { + _, _ = s.db.Exec(`UPDATE task_lists SET team_id = '' WHERE id = ? AND team_id = ?`, taskListID, targetID) + } + return err +} + +func (s *Store) GetTaskListShares(taskListID string) ([]domain.TaskListShare, error) { + rows, err := s.db.Query( + `SELECT id, task_list_id, target_type, target_id, created_at + FROM task_list_shares WHERE task_list_id = ? ORDER BY created_at ASC`, + taskListID, + ) + if err != nil { + return []domain.TaskListShare{}, err + } + defer rows.Close() + + shares := make([]domain.TaskListShare, 0) + for rows.Next() { + var sh domain.TaskListShare + if err := rows.Scan(&sh.ID, &sh.TaskListID, &sh.TargetType, &sh.TargetID, &sh.CreatedAt); err == nil { + shares = append(shares, sh) + } + } + for i := range shares { + if shares[i].TargetType == "team" { + _ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName) + } else { + _ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName, &shares[i].Email) + } + } + return shares, nil +} + +func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMember { + memberMap := make(map[string]domain.TaskListMember) + + // 1. Owner + var ownerName, ownerEmail string + _ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, tl.UserID).Scan(&ownerName, &ownerEmail) + memberMap[tl.UserID] = domain.TaskListMember{ + UserID: tl.UserID, + DisplayName: ownerName, + Email: ownerEmail, + Role: "owner", + } + + // 2. Team members if tl.TeamID != "" + if tl.TeamID != "" { + rows, err := s.db.Query( + `SELECT u.id, u.display_name, u.email FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ?`, + tl.TeamID, + ) + if err == nil { + for rows.Next() { + var m domain.TaskListMember + if err := rows.Scan(&m.UserID, &m.DisplayName, &m.Email); err == nil { + m.Role = "member" + memberMap[m.UserID] = m + } + } + _ = rows.Close() + } + } + + // 3. From task_list_shares + for _, sh := range tl.Shares { + if sh.TargetType == "team" { + rows, err := s.db.Query( + `SELECT u.id, u.display_name, u.email FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ?`, + sh.TargetID, + ) + if err == nil { + for rows.Next() { + var m domain.TaskListMember + if err := rows.Scan(&m.UserID, &m.DisplayName, &m.Email); err == nil { + m.Role = "member" + memberMap[m.UserID] = m + } + } + _ = rows.Close() + } + } else if sh.TargetType == "user" { + var m domain.TaskListMember + if err := s.db.QueryRow(`SELECT id, display_name, email FROM users WHERE id = ?`, sh.TargetID).Scan(&m.UserID, &m.DisplayName, &m.Email); err == nil { + m.Role = "member" + memberMap[m.UserID] = m + } + } + } + + result := make([]domain.TaskListMember, 0, len(memberMap)) + for _, m := range memberMap { + result = append(result, m) + } + return result } func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) { rows, err := s.db.Query( - `SELECT id, user_id, space_id, title, icon, color, created_at, updated_at + `SELECT id, user_id, space_id, COALESCE(team_id, ''), title, icon, color, created_at, updated_at FROM task_lists - WHERE user_id = ? OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)) + WHERE user_id = ? + OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + OR id IN ( + 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 = ?)) ORDER BY updated_at DESC`, - userID, userID, + userID, userID, userID, userID, userID, ) if err != nil { return []domain.TaskList{}, err @@ -2575,25 +2749,30 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) { list := make([]domain.TaskList, 0) for rows.Next() { var tl domain.TaskList - if err := rows.Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &tl.CreatedAt, &tl.UpdatedAt); err == nil { + if err := rows.Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &tl.CreatedAt, &tl.UpdatedAt); err == nil { + tl.IsOwner = (tl.UserID == userID) list = append(list, tl) } } _ = rows.Close() for i := range list { - list[i].Members = make([]domain.SpaceMember, 0) + list[i].Members = make([]domain.TaskListMember, 0) + list[i].Shares = make([]domain.TaskListShare, 0) + _ = s.db.QueryRow( `SELECT COUNT(*) FROM thoughts WHERE task_list_id = ? AND is_completed = 0 AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`, list[i].ID, ).Scan(&list[i].TasksCount) _ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, list[i].UserID).Scan(&list[i].OwnerName) - if list[i].SpaceID != "" { - _ = s.db.QueryRow(`SELECT name FROM spaces WHERE id = ?`, list[i].SpaceID).Scan(&list[i].SpaceName) - if members, err := s.GetSpaceMembers(list[i].SpaceID); err == nil && members != nil { - list[i].Members = members - } + + if list[i].TeamID != "" { + _ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, list[i].TeamID).Scan(&list[i].TeamName) } + + shares, _ := s.GetTaskListShares(list[i].ID) + list[i].Shares = shares + list[i].Members = s.getMembersForTaskList(&list[i]) } return list, nil } @@ -2601,26 +2780,36 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) { func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error) { var tl domain.TaskList err := s.db.QueryRow( - `SELECT id, user_id, space_id, title, icon, color, created_at, updated_at - FROM task_lists WHERE id = ? AND (user_id = ? OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)))`, - listID, userID, userID, - ).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &tl.CreatedAt, &tl.UpdatedAt) + `SELECT id, user_id, space_id, COALESCE(team_id, ''), title, icon, color, created_at, updated_at + FROM task_lists + WHERE id = ? AND ( + user_id = ? + OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + OR id IN ( + 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 = ?)) + )`, + listID, userID, userID, userID, userID, userID, + ).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &tl.CreatedAt, &tl.UpdatedAt) if err != nil { return nil, err } - tl.Members = make([]domain.SpaceMember, 0) + tl.IsOwner = (tl.UserID == userID) _ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE task_list_id = ? AND is_completed = 0 AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`, tl.ID).Scan(&tl.TasksCount) _ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, tl.UserID).Scan(&tl.OwnerName) - if tl.SpaceID != "" { - _ = s.db.QueryRow(`SELECT name FROM spaces WHERE id = ?`, tl.SpaceID).Scan(&tl.SpaceName) - if members, err := s.GetSpaceMembers(tl.SpaceID); err == nil && members != nil { - tl.Members = members - } + if tl.TeamID != "" { + _ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, tl.TeamID).Scan(&tl.TeamName) } + shares, _ := s.GetTaskListShares(tl.ID) + tl.Shares = shares + tl.Members = s.getMembersForTaskList(&tl) return &tl, nil } -func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, spaceID *string) (*domain.TaskList, error) { +func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID, spaceID *string) (*domain.TaskList, error) { tl, err := s.GetTaskListByID(userID, listID) if err != nil { return nil, err @@ -2637,10 +2826,20 @@ func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, spaceI if spaceID != nil { tl.SpaceID = *spaceID } + if teamID != nil { + oldTeamID := tl.TeamID + tl.TeamID = *teamID + if oldTeamID != "" && oldTeamID != *teamID { + _ = s.RemoveTaskListShare(listID, oldTeamID) + } + if *teamID != "" { + _, _ = s.AddTaskListShare(listID, "team", *teamID, userID) + } + } tl.UpdatedAt = domain.NowISO() _, err = s.db.Exec( - `UPDATE task_lists SET title = ?, icon = ?, color = ?, space_id = ?, updated_at = ? WHERE id = ? AND user_id = ?`, - tl.Title, tl.Icon, tl.Color, tl.SpaceID, tl.UpdatedAt, listID, userID, + `UPDATE task_lists SET title = ?, icon = ?, color = ?, space_id = ?, team_id = ?, updated_at = ? WHERE id = ?`, + tl.Title, tl.Icon, tl.Color, tl.SpaceID, tl.TeamID, tl.UpdatedAt, listID, ) if err != nil { return nil, err @@ -2650,6 +2849,7 @@ func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, spaceI func (s *Store) DeleteTaskList(userID, listID string) error { _, _ = s.db.Exec(`UPDATE thoughts SET task_list_id = '' WHERE task_list_id = ?`, listID) + _, _ = s.db.Exec(`DELETE FROM task_list_shares WHERE task_list_id = ?`, listID) _, err := s.db.Exec(`DELETE FROM task_lists WHERE id = ? AND user_id = ?`, listID, userID) return err } diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index b55bab4..c3c25b0 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -99,7 +99,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, sp.ID, "", "Haus & Garten", "🏡", "#10b981") if err != nil || taskList.ID == "" { t.Fatalf("CreateTaskList failed: %v", err) } @@ -122,6 +122,20 @@ func TestStoreLifecycle(t *testing.T) { t.Fatalf("AddTeamMember failed: %v", err) } + // Test TaskList Sharing with Team + shareTeam, err := store.AddTaskListShare(taskList.ID, "team", team.ID, user.ID) + if err != nil || shareTeam.ID == "" { + t.Fatalf("AddTaskListShare team failed: %v", err) + } + tlShares, err := store.GetTaskListShares(taskList.ID) + if err != nil || len(tlShares) == 0 { + t.Fatalf("GetTaskListShares failed: %v", err) + } + fetchedTL, err := store.GetTaskListByID(user.ID, taskList.ID) + if err != nil || len(fetchedTL.Shares) == 0 { + t.Fatalf("GetTaskListByID with shares failed: %v", err) + } + // 7. Test System Settings sysSettings, err := store.GetSystemSettings() if err != nil || sysSettings.InstanceName == "" {