feat(tasks): assign tasks in shared lists, unified people search, list avatars
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m36s

- Tasks in shared task lists can be assigned to one or more people with access
  (task_assignees); assignments are pruned whenever access is lost
- UserAutocomplete: type-to-search everywhere, nothing before typing, max 3 results,
  first match preselected
- Task list and list tabs: avatar stack of members and round monochrome counter
- Shared list items remember who added them (list_items.created_by)
- Doku/ui-patterns.md documents the shared UI patterns

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-28 20:26:55 +01:00
1 parent 1bd38f4d50
commit 73fad1c5db
21 files changed
+598 -67

No files matched your search

+2 -2
View File
@@ -119,7 +119,7 @@ func (s *Store) SeedStarterContent(user *domain.User) error {
{"Spülmaschinentabs", "Drogerie", "1 Pack"},
}
for i, it := range items {
item, err := s.CreateListItem(shop.ID, it.content, it.category, it.qty, i)
item, err := s.CreateListItem(shop.ID, it.content, it.category, it.qty, i, user.ID)
if err == nil && (i == 1 || i == 6 || i == 8) {
_, _ = s.ToggleListItem(item.ID, true, user.ID, user.DisplayName)
}
@@ -132,7 +132,7 @@ func (s *Store) SeedStarterContent(user *domain.User) error {
{"Erste-Hilfe-Set", "Ausrüstung"}, {"Stirnlampe", "Ausrüstung"}, {"Trinkflaschen", "Ausrüstung"},
{"Personalausweise", "Dokumente"}, {"Hüttenschlafsack", "Ausrüstung"},
} {
_, _ = s.CreateListItem(packing.ID, it.content, it.category, "", i)
_, _ = s.CreateListItem(packing.ID, it.content, it.category, "", i, user.ID)
}
}
+201 -10
View File
@@ -398,6 +398,13 @@ func (s *Store) migrate() error {
"CREATE TABLE IF NOT EXISTS block_tags (tag TEXT NOT NULL, document_id TEXT NOT NULL, block_id TEXT NOT NULL, PRIMARY KEY(tag, block_id), FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE, FOREIGN KEY(block_id) REFERENCES blocks(id) ON DELETE CASCADE);",
"CREATE INDEX IF NOT EXISTS idx_block_tags_tag ON block_tags(tag);",
"CREATE INDEX IF NOT EXISTS idx_block_tags_doc ON block_tags(document_id);",
// Task assignments: who a task in a shared task list is assigned to
"CREATE TABLE IF NOT EXISTS task_assignees (task_id TEXT NOT NULL, user_id TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(task_id, user_id), FOREIGN KEY(task_id) REFERENCES thoughts(id) ON DELETE CASCADE);",
"CREATE INDEX IF NOT EXISTS idx_task_assignees_user ON task_assignees(user_id);",
// Who added an item to a (shopping) list
"ALTER TABLE list_items ADD COLUMN created_by TEXT NOT NULL DEFAULT '';",
}
for _, q := range columnMigrations {
_, _ = s.db.Exec(q)
@@ -812,6 +819,7 @@ func (s *Store) UpdateUserPassword(userID, passwordHash string) error {
func (s *Store) DeleteUser(userID string) error {
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID)
_, _ = s.db.Exec(`DELETE FROM task_assignees WHERE user_id = ?`, userID)
_, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID)
return err
}
@@ -956,6 +964,7 @@ func scanThought(rows interface {
t.DocID = parts[0]
t.BlockID = parts[1]
}
t.Assignees = []string{}
t.Subtasks = []domain.SubTask{}
if subtasksStr != "" && subtasksStr != "null" {
_ = json.Unmarshal([]byte(subtasksStr), &t.Subtasks)
@@ -980,7 +989,12 @@ func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
thoughtID, userID, userID, userID, userID, userID,
)
return scanThought(row)
t, err := scanThought(row)
if err != nil {
return nil, err
}
t.Assignees = s.getTaskAssignees(t.ID)
return t, nil
}
func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Thought, error) {
@@ -1055,6 +1069,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
}
}
_ = rows.Close()
s.attachTaskAssignees(result)
return result, nil
}
@@ -1190,7 +1205,7 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
} else if convertTo == "list_item" && targetListID != "" {
newStatus = domain.StatusConverted
convertedType = "list_item"
item, err := s.CreateListItem(targetListID, thought.Content, "", "", 0)
item, err := s.CreateListItem(targetListID, thought.Content, "", "", 0, userID)
if err == nil && item != nil {
convertedRefID = item.ID
}
@@ -1208,6 +1223,9 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
if err != nil {
return nil, err
}
if taskListID != thought.TaskListID {
s.PruneTaskAssignees()
}
return s.GetThoughtByID(userID, thoughtID)
}
@@ -1285,6 +1303,10 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
if err != nil {
return nil, err
}
if taskListID != nil {
s.PruneTaskAssignees()
thought.Assignees = s.getTaskAssignees(thoughtID)
}
return thought, nil
}
@@ -3625,6 +3647,7 @@ func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
for i := range lists {
lists[i].Items, _ = s.GetListItems(lists[i].ID)
lists[i].Shares, _ = s.GetListShares(lists[i].ID)
lists[i].Members = s.getListMembers(lists[i].ID, lists[i].UserID)
}
return lists, nil
}
@@ -3673,9 +3696,38 @@ func (s *Store) GetListByID(userID, listID string) (*domain.SharedList, error) {
l.Role = effectiveRole
l.Items, _ = s.GetListItems(l.ID)
l.Shares, _ = s.GetListShares(l.ID)
l.Members = s.getListMembers(l.ID, l.UserID)
return &l, nil
}
// getListMembers returns the owner and everyone a list is shared with (directly or via a team).
func (s *Store) getListMembers(listID, ownerID string) []domain.TaskListMember {
members := make([]domain.TaskListMember, 0)
rows, err := s.db.Query(
`SELECT id, display_name, email, avatar_url FROM users WHERE id IN (
SELECT user_id FROM lists WHERE id = ?
UNION SELECT target_id FROM list_shares WHERE list_id = ? AND target_type = 'user'
UNION SELECT user_id FROM team_members WHERE team_id IN (SELECT target_id FROM list_shares WHERE list_id = ? AND target_type = 'team')
) ORDER BY display_name`,
listID, listID, listID,
)
if err != nil {
return members
}
defer rows.Close()
for rows.Next() {
var m domain.TaskListMember
if rows.Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL) == nil {
m.Role = "member"
if m.UserID == ownerID {
m.Role = "owner"
}
members = append(members, m)
}
}
return members
}
func (s *Store) DeleteList(userID, listID string) error {
var ownerID string
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
@@ -3691,9 +3743,11 @@ func (s *Store) DeleteList(userID, listID string) error {
func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) {
rows, err := s.db.Query(
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
FROM list_items WHERE list_id = ?
ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
`SELECT li.id, li.list_id, li.content, li.category, li.quantity, li.is_completed, li.completed_by, li.completed_by_name,
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at
FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != ''
WHERE li.list_id = ?
ORDER BY li.is_completed ASC, li.sort_order ASC, li.created_at DESC`,
listID,
)
if err != nil {
@@ -3705,7 +3759,7 @@ func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) {
for rows.Next() {
var it domain.ListItem
var doneInt int
if err := rows.Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &doneInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt); err == nil {
if err := rows.Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &doneInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt); err == nil {
it.IsCompleted = doneInt != 0
items = append(items, it)
}
@@ -3713,7 +3767,7 @@ func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) {
return items, nil
}
func (s *Store) CreateListItem(listID, content, category, quantity string, sortOrder int) (*domain.ListItem, error) {
func (s *Store) CreateListItem(listID, content, category, quantity string, sortOrder int, createdBy string) (*domain.ListItem, error) {
content = strings.TrimSpace(content)
if content == "" {
return nil, errors.New("item content cannot be empty")
@@ -3743,13 +3797,17 @@ func (s *Store) CreateListItem(listID, content, category, quantity string, sortO
Quantity: strings.TrimSpace(quantity),
IsCompleted: false,
SortOrder: sortOrder,
CreatedBy: createdBy,
CreatedAt: now,
}
if createdBy != "" {
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, createdBy).Scan(&it.CreatedByName)
}
_, err := s.db.Exec(
`INSERT INTO list_items (id, list_id, content, category, quantity, is_completed, sort_order, created_at)
VALUES (?, ?, ?, ?, ?, 0, ?, ?)`,
it.ID, it.ListID, it.Content, it.Category, it.Quantity, it.SortOrder, it.CreatedAt,
`INSERT INTO list_items (id, list_id, content, category, quantity, is_completed, sort_order, created_at, created_by)
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)`,
it.ID, it.ListID, it.Content, it.Category, it.Quantity, it.SortOrder, it.CreatedAt, it.CreatedBy,
)
if err != nil {
return nil, err
@@ -4173,6 +4231,9 @@ func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color strin
// ErrTaskListReadOnly is returned when a user with viewer permission tries to modify a task list or its tasks.
var ErrTaskListReadOnly = errors.New("Keine Schreibrechte für diese Aufgabenliste (nur Lesen)")
// ErrAssigneeNoAccess is returned when a task should be assigned to someone who cannot see its list.
var ErrAssigneeNoAccess = errors.New("Aufgaben können nur Personen mit Zugriff auf die Liste zugewiesen werden")
// GetTaskListPermission returns "owner", "editor", "viewer" or "" (no access) for the given user.
func (s *Store) GetTaskListPermission(userID, listID string) string {
var ownerID, teamID string
@@ -4283,6 +4344,7 @@ func (s *Store) RemoveTaskListShare(taskListID, shareIDOrTargetID string) error
if targetType == "team" {
_, _ = s.db.Exec(`UPDATE task_lists SET team_id = '' WHERE id = ? AND team_id = ?`, taskListID, targetID)
}
s.PruneTaskAssignees()
return err
}
@@ -4333,6 +4395,129 @@ func (s *Store) queryUserIDs(query string, args ...interface{}) []string {
return ids
}
// taskListAccessSQL selects every user ID with access to the task list given as the single parameter.
// It mirrors GetTaskListUserIDs and is correlated into PruneTaskAssignees.
const taskListAccessSQL = `SELECT user_id FROM task_lists WHERE id = %[1]s
UNION SELECT target_id FROM task_list_shares WHERE task_list_id = %[1]s AND target_type = 'user'
UNION SELECT tm.user_id FROM team_members tm
WHERE tm.team_id IN (SELECT target_id FROM task_list_shares WHERE task_list_id = %[1]s AND target_type = 'team')
OR tm.team_id IN (SELECT team_id FROM task_lists WHERE id = %[1]s AND team_id != '')`
func (s *Store) getTaskAssignees(taskID string) []string {
ids := s.queryUserIDs(`SELECT user_id FROM task_assignees WHERE task_id = ? ORDER BY created_at ASC`, taskID)
return ids
}
// attachTaskAssignees fills Assignees for a batch of tasks with a single query.
func (s *Store) attachTaskAssignees(tasks []domain.Thought) {
if len(tasks) == 0 {
return
}
idx := make(map[string]int, len(tasks))
placeholders := make([]string, 0, len(tasks))
args := make([]interface{}, 0, len(tasks))
for i := range tasks {
idx[tasks[i].ID] = i
placeholders = append(placeholders, "?")
args = append(args, tasks[i].ID)
}
rows, err := s.db.Query(
`SELECT task_id, user_id FROM task_assignees WHERE task_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY created_at ASC`,
args...,
)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var taskID, uid string
if rows.Scan(&taskID, &uid) == nil {
if i, ok := idx[taskID]; ok {
tasks[i].Assignees = append(tasks[i].Assignees, uid)
}
}
}
}
// SetTaskAssignees replaces the assignees of a task. Only editors of the task's list may assign, and only
// people who can see the list are accepted.
func (s *Store) SetTaskAssignees(userID, taskID string, assignees []string) ([]string, error) {
thought, err := s.GetThoughtByID(userID, taskID)
if err != nil {
return nil, err
}
if thought.TaskListID == "" {
if len(assignees) > 0 {
return nil, ErrAssigneeNoAccess
}
return []string{}, nil
}
if !s.CanEditTaskList(userID, thought.TaskListID) {
return nil, ErrTaskListReadOnly
}
allowed := make(map[string]bool)
for _, id := range s.GetTaskListUserIDs(thought.TaskListID) {
allowed[id] = true
}
clean := make([]string, 0, len(assignees))
seen := make(map[string]bool)
for _, id := range assignees {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
continue
}
if !allowed[id] {
return nil, ErrAssigneeNoAccess
}
seen[id] = true
clean = append(clean, id)
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
keep := make([]interface{}, 0, len(clean)+1)
keep = append(keep, taskID)
ph := make([]string, 0, len(clean))
for _, id := range clean {
keep = append(keep, id)
ph = append(ph, "?")
}
delQuery := `DELETE FROM task_assignees WHERE task_id = ?`
if len(ph) > 0 {
delQuery += ` AND user_id NOT IN (` + strings.Join(ph, ",") + `)`
}
if _, err := tx.Exec(delQuery, keep...); err != nil {
return nil, err
}
now := domain.NowISO()
for _, id := range clean {
if _, err := tx.Exec(`INSERT OR IGNORE INTO task_assignees (task_id, user_id, created_at) VALUES (?, ?, ?)`, taskID, id, now); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return s.getTaskAssignees(taskID), nil
}
// PruneTaskAssignees drops every assignment whose user can no longer see the task's list (share removed,
// left the team, team deleted, task moved to another list or out of any list). Called after each change
// that can take access away, so assignments never outlive permissions.
func (s *Store) PruneTaskAssignees() {
_, _ = s.db.Exec(
`DELETE FROM task_assignees WHERE rowid IN (
SELECT ta.rowid FROM task_assignees ta
LEFT JOIN thoughts t ON t.id = ta.task_id
WHERE t.id IS NULL OR t.task_list_id = ''
OR ta.user_id NOT IN (` + fmt.Sprintf(taskListAccessSQL, "t.task_list_id") + `)
)`,
)
}
// GetTaskListUserIDs returns every user who can see the task list: owner, direct shares, members of shared teams
// and members of the legacy team_id.
func (s *Store) GetTaskListUserIDs(listID string) []string {
@@ -4574,6 +4759,9 @@ func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID
if err != nil {
return nil, err
}
if teamID != nil {
s.PruneTaskAssignees()
}
return s.GetTaskListByID(userID, listID)
}
@@ -4581,6 +4769,7 @@ 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)
s.PruneTaskAssignees()
return err
}
@@ -4709,6 +4898,7 @@ func (s *Store) CreateTeam(orgID, name, description string) (*domain.Team, error
func (s *Store) DeleteTeam(teamID string) error {
_, err := s.db.Exec(`DELETE FROM teams WHERE id = ?`, teamID)
s.PruneTaskAssignees()
return err
}
@@ -4888,6 +5078,7 @@ func (s *Store) RemoveTeamMember(teamID, userID string) error {
}
}
_, err = s.db.Exec(`DELETE FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID)
s.PruneTaskAssignees()
return err
}
+1 -1
View File
@@ -54,7 +54,7 @@ func TestStoreLifecycle(t *testing.T) {
if err != nil {
t.Fatalf("CreateList failed: %v", err)
}
item, err := store.CreateListItem(lst.ID, "Hafermilch", "Kühlregal", "2L", 0)
item, err := store.CreateListItem(lst.ID, "Hafermilch", "Kühlregal", "2L", 0, user.ID)
if err != nil {
t.Fatalf("CreateListItem failed: %v", err)
}
+116
View File
@@ -0,0 +1,116 @@
package storage
import (
"errors"
"path/filepath"
"testing"
)
func TestTaskAssigneesFollowListAccess(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "assignees.db"))
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
owner, _ := store.EnsureDefaultUser()
direct, _ := store.CreateUser("direct@example.com", "hash", "Direkt", "member")
viaTeam, _ := store.CreateUser("team@example.com", "hash", "Team", "member")
stranger, _ := store.CreateUser("stranger@example.com", "hash", "Fremd", "member")
tl, err := store.CreateTaskList(owner.ID, "", "", "Haushalt", "🏠", "")
if err != nil {
t.Fatalf("CreateTaskList failed: %v", err)
}
task, err := store.CreateThoughtWithDetails(owner.ID, "Fenster putzen", "normal", "manual", "", tl.ID, "todo", "")
if err != nil {
t.Fatalf("CreateThoughtWithDetails failed: %v", err)
}
if _, err := store.UpdateThoughtTriage(owner.ID, task.ID, "keep", "task", "", "", tl.ID); err != nil {
t.Fatalf("UpdateThoughtTriage failed: %v", err)
}
org, _ := store.CreateOrganization(owner.ID, "Familie")
team, err := store.CreateTeam(org.ID, "WG", "")
if err != nil {
t.Fatalf("CreateTeam failed: %v", err)
}
_ = store.AddTeamMember(team.ID, owner.ID, "admin")
_ = store.AddTeamMember(team.ID, viaTeam.ID, "member")
if _, err := store.AddTaskListShare(tl.ID, "user", direct.ID, "editor", owner.ID); err != nil {
t.Fatalf("AddTaskListShare user failed: %v", err)
}
if _, err := store.AddTaskListShare(tl.ID, "team", team.ID, "viewer", owner.ID); err != nil {
t.Fatalf("AddTaskListShare team failed: %v", err)
}
if _, err := store.SetTaskAssignees(owner.ID, task.ID, []string{stranger.ID}); !errors.Is(err, ErrAssigneeNoAccess) {
t.Fatalf("assigning someone without access must fail, got %v", err)
}
if _, err := store.SetTaskAssignees(viaTeam.ID, task.ID, []string{viaTeam.ID}); !errors.Is(err, ErrTaskListReadOnly) {
t.Fatalf("viewer must not assign, got %v", err)
}
got, err := store.SetTaskAssignees(direct.ID, task.ID, []string{owner.ID, direct.ID, viaTeam.ID, direct.ID})
if err != nil || len(got) != 3 {
t.Fatalf("expected 3 assignees, got %v (%v)", got, err)
}
tasks, _ := store.GetTasks(owner.ID, tl.ID)
if len(tasks) != 1 || len(tasks[0].Assignees) != 3 {
t.Fatalf("GetTasks should carry assignees, got %+v", tasks)
}
// Leaving the team removes the team member's assignment
if err := store.RemoveTeamMember(team.ID, viaTeam.ID); err != nil {
t.Fatalf("RemoveTeamMember failed: %v", err)
}
assertAssignees(t, store, owner.ID, task.ID, owner.ID, direct.ID)
// Removing the direct share removes that user's assignment, the owner stays
if err := store.RemoveTaskListShare(tl.ID, direct.ID); err != nil {
t.Fatalf("RemoveTaskListShare failed: %v", err)
}
assertAssignees(t, store, owner.ID, task.ID, owner.ID)
// Access through a second path keeps the assignment
_ = store.AddTeamMember(team.ID, direct.ID, "member")
_, _ = store.AddTaskListShare(tl.ID, "user", direct.ID, "editor", owner.ID)
if _, err := store.SetTaskAssignees(owner.ID, task.ID, []string{owner.ID, direct.ID}); err != nil {
t.Fatalf("re-assign failed: %v", err)
}
_ = store.RemoveTaskListShare(tl.ID, direct.ID)
assertAssignees(t, store, owner.ID, task.ID, owner.ID, direct.ID)
// Deleting the team takes the last path away
if err := store.DeleteTeam(team.ID); err != nil {
t.Fatalf("DeleteTeam failed: %v", err)
}
assertAssignees(t, store, owner.ID, task.ID, owner.ID)
// Moving the task out of the list drops all assignments
empty := ""
if _, err := store.UpdateThoughtDetails(owner.ID, task.ID, nil, nil, nil, nil, &empty, nil, nil, nil, nil); err != nil {
t.Fatalf("UpdateThoughtDetails failed: %v", err)
}
assertAssignees(t, store, owner.ID, task.ID)
}
func assertAssignees(t *testing.T, store *Store, viewerID, taskID string, want ...string) {
t.Helper()
th, err := store.GetThoughtByID(viewerID, taskID)
if err != nil {
t.Fatalf("GetThoughtByID failed: %v", err)
}
if len(th.Assignees) != len(want) {
t.Fatalf("expected assignees %v, got %v", want, th.Assignees)
}
have := map[string]bool{}
for _, id := range th.Assignees {
have[id] = true
}
for _, id := range want {
if !have[id] {
t.Fatalf("expected assignees %v, got %v", want, th.Assignees)
}
}
}