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
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:
1 parent
1bd38f4d50
commit
73fad1c5db
21 files changed
+598
-67
No files matched your search
+201
-10
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user