feat(tasks): enable sharing task lists with teams and users
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m6s

This commit is contained in:
sandrodach committed 2026-10-25 13:00:17 +01:00
1 parent cfc0477b5c
commit 0afa2f9c2d
7 files changed
+686 -173

No files matched your search

+79 -5
View File
@@ -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})
}
+33 -12
View File
@@ -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 {
+236 -36
View File
@@ -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
}
+15 -1
View File
@@ -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 == "" {