feat(views): "Venter på andre", and a project you share stays yours

`assignee_id` has been on tasks since sharing was built and delegating has
been possible from the drawer since yesterday. What was missing was
anywhere to see it: work handed to somebody else left your lists and
appeared in nothing.

GET /delegated returns everything assigned to somebody who is not you, in
the projects you can see, grouped by the person. Grouped on the server
rather than flat, because the question is about people — "what is Anders
sitting on" — and a client cannot group it itself: it would need the name
behind an assignee id from a project it does not happen to have open, and
there is no way to ask for that. The names come back with the tasks, so
one request is one screen.

The filter is a field of its own rather than a sentinel in AssigneeID.
"What is on my plate" and "what have I handed over" are different
questions, and one string that means an id most of the time and a magic
word the rest is a field somebody eventually passes a real id to.

An assignee whose account is gone is named "Ukendt" rather than dropped.
Losing the row would make work disappear from the one view that exists to
stop work disappearing.

The sidebar entry only appears where delegating is possible at all. On an
instance with one person it could never have anything in it, and a
permanent empty entry is a question the sidebar keeps asking and answering
"no".

Building it turned up a bug in the split above it. "Projekter" and "Delt
med mig" were divided on `shared`, which only means the project has more
than one member — so inviting somebody to a project of your own moved it
under "Delt med mig", where it lost its place in the order, stopped being
draggable, and could not be filed under a group. It is divided on
ownership now, which is what the two headings have always said. Sharing
your own project is not the same event as somebody sharing theirs with
you.

The smoke test now follows the whole loop in one go: invite, accept in a
second browser, assign a task to the person who just joined, and find it
under their name — with the shared project still under "Projekter" on the
way past.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Kristian Wind
2026-08-17 19:36:37 +02:00
co-authored by Claude Opus 5
parent 34b718529c
commit 6b6b838167
11 changed files with 436 additions and 5 deletions
+101 -1
View File
@@ -1,6 +1,9 @@
package httpapi
import "testing"
import (
"net/http"
"testing"
)
// A label is how you find a task whose wording you have forgotten. Search
// covered the text only, so "regnskab" found nothing even with the task
@@ -53,3 +56,100 @@ func TestSearchByLabelStaysWithinWhatYouCanSee(t *testing.T) {
t.Errorf("found somebody else's task: %v", tasks)
}
}
// "Waiting on others" is the other half of an assignee filter, and the half that
// is easy to get backwards: what somebody has handed over, not what they have
// been handed.
func TestDelegatedListsWhatOtherPeopleAreSittingOn(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.newUser(t, "[email protected]", "Anders")
anders, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
me, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
"email": "[email protected]", "role": "editor",
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "anders skriver rapporten", "project_id": projectID, "assignee_id": anders.ID,
})
// Mine, and unassigned: neither belongs in a view about other people.
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "jeg læser den", "project_id": projectID, "assignee_id": me.ID,
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "ingen har den", "project_id": projectID,
})
resp, body := ts.do(t, "GET", "/api/v1/delegated", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("delegated: %d %v", resp.StatusCode, body)
}
people, _ := body["people"].([]any)
if len(people) != 1 {
t.Fatalf("want one person, got %v", people)
}
person := people[0].(map[string]any)
if person["name"] != "Anders" {
t.Errorf("name = %v — the view has to carry the name, or the client has no way to ask for it",
person["name"])
}
if person["avatar_color"] == nil || person["avatar_color"] == "" {
t.Errorf("no avatar colour: %v", person)
}
tasks, _ := person["tasks"].([]any)
if len(tasks) != 1 {
t.Fatalf("want one task, got %v", tasks)
}
if tasks[0].(map[string]any)["content"] != "anders skriver rapporten" {
t.Errorf("content = %v", tasks[0])
}
}
// The view is per person: Anders is not waiting on himself, and he cannot see
// what somebody has delegated in a project he is not in.
func TestDelegatedIsPerPersonAndRespectsAccess(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
anders := ts.newUser(t, "[email protected]", "Anders")
andersUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
// A project Anders is not a member of.
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
"email": "[email protected]", "role": "editor",
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "anders gør det", "project_id": projectID, "assignee_id": andersUser.ID,
})
// It is assigned to him, so it is not something he is waiting on.
_, body := anders.do(t, "GET", "/api/v1/delegated", nil)
if people, _ := body["people"].([]any); len(people) != 0 {
t.Errorf("Anders is waiting on himself: %v", people)
}
// And somebody with no access to the project sees none of it.
other := ts.newUser(t, "[email protected]", "Tredje")
_, third := other.do(t, "GET", "/api/v1/delegated", nil)
if people, _ := third["people"].([]any); len(people) != 0 {
t.Errorf("a task in a project they cannot see: %v", people)
}
}