diff --git a/internal/http/board.go b/internal/http/board.go index e3dd664..50a4c57 100644 --- a/internal/http/board.go +++ b/internal/http/board.go @@ -33,30 +33,56 @@ func (s *Server) routesBoard(mux *http.ServeMux) { mux.Handle("GET /api/v1/users/{id}/card", s.requireAuth(http.HandlerFunc(s.handleUserCard))) } -// boardConsultants resolves which consultants' tasks the developer sees: -// the consultants who selected them into a pool (§3). -func (s *Server) boardConsultants(r *http.Request) ([]string, error) { - return s.store.PoolConsultantIDs(r.Context(), CurrentUser(r.Context()).ID) +// boardCustomers resolves the developer's visibility scope: every customer +// that has at least one consultant who selected this developer into a pool. +// Visibility is customer-based, not task-owner-based — §4.4: all consultants +// assigned to a customer manage its tasks, so a task published by any of +// them belongs to the same board. +func (s *Server) boardCustomers(r *http.Request) ([]domain.Customer, error) { + consultants, err := s.store.PoolConsultantIDs(r.Context(), CurrentUser(r.Context()).ID) + if err != nil { + return nil, err + } + seen := map[string]bool{} + out := []domain.Customer{} + for _, cid := range consultants { + customers, err := s.store.ListCustomers(r.Context(), cid, false) + if err != nil { + return nil, err + } + for _, c := range customers { + if !seen[c.ID] { + seen[c.ID] = true + out = append(out, c) + } + } + } + return out, nil } func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request) { - consultants, err := s.boardConsultants(r) + customers, err := s.boardCustomers(r) if err != nil { - s.internalError(w, r, "pool lookup", err) + s.internalError(w, r, "board scope", err) return } q := r.URL.Query() + customerIDs := []string{} + for _, c := range customers { + if want := q.Get("customerId"); want == "" || want == c.ID { + customerIDs = append(customerIDs, c.ID) + } + } tasks := []domain.Task{} - if len(consultants) > 0 { + if len(customerIDs) > 0 { minBounty, _ := strconv.ParseFloat(q.Get("minBounty"), 64) filter := store.TaskFilter{ - ConsultantIDs: consultants, - Statuses: []domain.TaskStatus{domain.StatusPublished, domain.StatusClaimRequested}, - Search: strings.TrimSpace(q.Get("q")), - MinBounty: minBounty, - CustomerID: q.Get("customerId"), - Sort: q.Get("sort"), // newest (default) | bounty - Limit: 200, + CustomerIDs: customerIDs, + Statuses: []domain.TaskStatus{domain.StatusPublished, domain.StatusClaimRequested}, + Search: strings.TrimSpace(q.Get("q")), + MinBounty: minBounty, + Sort: q.Get("sort"), // newest (default) | bounty + Limit: 200, } if tasks, err = s.store.ListTasks(r.Context(), filter); err != nil { s.internalError(w, r, "list board", err) @@ -78,18 +104,12 @@ func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request) { tasks[i].ClaimRequests = mine } - // customers for the filter dropdown - customerIDs := map[string]bool{} - for _, t := range tasks { - customerIDs[t.CustomerID] = true + // customers for the filter dropdown (full visibility scope) + customersOut := make([]map[string]any, len(customers)) + for i, c := range customers { + customersOut[i] = map[string]any{"id": c.ID, "name": c.Name} } - customers := []map[string]any{} - for id := range customerIDs { - if c, err := s.store.CustomerByID(r.Context(), id); err == nil { - customers = append(customers, map[string]any{"id": c.ID, "name": c.Name}) - } - } - writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customers": customers}) + writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customers": customersOut}) } func (s *Server) handleMyTasks(w http.ResponseWriter, r *http.Request) { @@ -110,8 +130,9 @@ func (s *Server) handleMyTasks(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks}) } -// developerTask loads a task ensuring the developer can see it via a pool -// relationship with its consultant. +// developerTask loads a task ensuring the developer can see it: the task's +// customer is in their pool-derived visibility scope, or they are the +// assignee (access survives mid-flight pool removal). func (s *Server) developerTask(w http.ResponseWriter, r *http.Request, id string) (*domain.Task, bool) { t, err := s.store.TaskByID(r.Context(), id) if err != nil { @@ -122,20 +143,19 @@ func (s *Server) developerTask(w http.ResponseWriter, r *http.Request, id string } return nil, false } - consultants, err := s.boardConsultants(r) - if err != nil { - s.internalError(w, r, "pool lookup", err) - return nil, false - } - for _, cid := range consultants { - if cid == t.ConsultantID { - return t, true - } - } - // assignees keep access even if removed from the pool mid-flight if t.IsAssignedTo(CurrentUser(r.Context()).ID) { return t, true } + customers, err := s.boardCustomers(r) + if err != nil { + s.internalError(w, r, "board scope", err) + return nil, false + } + for _, c := range customers { + if c.ID == t.CustomerID { + return t, true + } + } writeError(w, http.StatusForbidden, "forbidden", "this task is not on your board") return nil, false } diff --git a/internal/http/lifecycle_integration_test.go b/internal/http/lifecycle_integration_test.go index 986fe78..aa89df8 100644 --- a/internal/http/lifecycle_integration_test.go +++ b/internal/http/lifecycle_integration_test.go @@ -295,6 +295,56 @@ func TestDeclineWithdrawUnassignAbandon(t *testing.T) { } } +// TestBoardVisibilityIsCustomerBased: a developer pooled by consultant A +// sees tasks published by consultant B on the same customer (§4.4 — all +// consultants of a customer manage its tasks). +func TestBoardVisibilityIsCustomerBased(t *testing.T) { + l := newLifecycleStack(t, nil) + + // second consultant on the same customer publishes their own task + bc := newClient(t) + consB := registerUser(t, l.ts.URL, bc, "lc-cons-b@example.com", "Cons B") + promote(t, l.st, consB.ID, map[string]bool{"consultant": true}) + if _, err := l.st.DB.Collection("customers").UpdateOne(t.Context(), + bson.M{"_id": l.customerID}, + bson.M{"$push": bson.M{"consultantIds": consB.ID}}); err != nil { + t.Fatal(err) + } + taskB := &domain.Task{ + CustomerID: l.customerID, ConsultantID: consB.ID, + Origin: domain.OriginSubdivided, Status: domain.StatusAtomized, + Title: "Task owned by consultant B", EffortCoefficient: 0.5, Budget: 1000, Bounty: 500, + } + if err := l.st.InsertTask(t.Context(), taskB); err != nil { + t.Fatal(err) + } + bCSRF := csrfFrom(t, bc, l.ts.URL) + mustPost(t, bc, l.ts.URL+"/api/v1/tasks/"+taskB.ID+"/publish", map[string]any{}, bCSRF, http.StatusOK).Body.Close() + + // the developer is pooled ONLY by consultant A, yet sees B's task + resp, err := l.developer.Get(l.ts.URL + "/api/v1/board") + if err != nil { + t.Fatal(err) + } + var board struct { + Tasks []domain.Task `json:"tasks"` + } + bodyJSON(t, resp, &board) + found := false + for _, tk := range board.Tasks { + if tk.ID == taskB.ID { + found = true + } + } + if !found { + t.Fatalf("developer pooled by consultant A cannot see consultant B's task on the same customer: %+v", board.Tasks) + } + + // and can claim it + mustPost(t, l.developer, l.ts.URL+"/api/v1/tasks/"+taskB.ID+"/claim", + map[string]string{"note": "cross-consultant"}, l.devCSRF, http.StatusNoContent).Body.Close() +} + func TestDeveloperOutsidePoolSeesNothing(t *testing.T) { l := newLifecycleStack(t, nil) base := l.ts.URL + "/api/v1/tasks/" + l.task.ID diff --git a/internal/http/web.go b/internal/http/web.go index 5cb68d5..3a43a83 100644 --- a/internal/http/web.go +++ b/internal/http/web.go @@ -1,6 +1,8 @@ package httpx import ( + "crypto/sha256" + "encoding/hex" "fmt" "html" "html/template" @@ -126,8 +128,19 @@ func (s *Server) routesWeb(mux *http.ServeMux) { if err != nil { panic(err) // embed layout is fixed at compile time } - mux.Handle("GET /static/", http.StripPrefix("/static/", - cacheControl(http.FileServerFS(staticFS), "public, max-age=3600"))) + // no-cache + a build-wide strong ETag: browsers revalidate on every + // load (cheap 304) and can never run a stale CSS/JS mix after a deploy + etag := staticETag(staticFS) + fileServer := http.StripPrefix("/static/", http.FileServerFS(staticFS)) + mux.Handle("GET /static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("ETag", etag) + fileServer.ServeHTTP(w, r) + })) mux.HandleFunc("GET /{$}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) { s.render(w, r, "home.html", &pageData{Title: "Home", User: u}) @@ -273,9 +286,20 @@ func loginErrorMessage(code string) string { } } -func cacheControl(next http.Handler, value string) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Cache-Control", value) - next.ServeHTTP(w, r) +// staticETag hashes every embedded static asset into one build-wide ETag. +func staticETag(fsys fs.FS) string { + h := sha256.New() + fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + data, err := fs.ReadFile(fsys, path) + if err != nil { + return err + } + h.Write([]byte(path)) + h.Write(data) + return nil }) + return `"` + hex.EncodeToString(h.Sum(nil))[:20] + `"` } diff --git a/web/static/css/app.css b/web/static/css/app.css index a384e24..71cb578 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -659,7 +659,11 @@ legend { background-image: var(--dither); background-color: var(--surface2); } -.chat-panel header strong { font-family: var(--font-display); font-size: 1rem; } +.chat-panel header strong { + font-family: var(--font-display); font-size: 1rem; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.chat-panel header .btn { background: var(--surface); } .chat-panel .cw-body { flex: 1; overflow: auto; padding: 6px 10px; } .chat-panel .cw-conv { display: flex; justify-content: space-between; align-items: center; gap: 8px; diff --git a/web/static/js/atomization.js b/web/static/js/atomization.js index 5b27220..9c5bc3b 100644 --- a/web/static/js/atomization.js +++ b/web/static/js/atomization.js @@ -149,6 +149,8 @@ function renderChildren(parent, kids, depth) { + ${['imported', 'atomized'].includes(k.status) + ? '' : ''} ${k.status === 'atomized' ? '' : ''} @@ -183,6 +185,8 @@ function renderChildren(parent, kids, depth) { }); } row.querySelector('[data-act=edit]').addEventListener('click', () => openEdit(k, false)); + const sd = row.querySelector('[data-act=subdivide]'); + if (sd) sd.addEventListener('click', () => openSubdivide(k)); row.querySelector('[data-act=extend]').addEventListener('click', () => openExtend(k)); row.querySelector('[data-act=archive]').addEventListener('click', () => archive(k)); const pub = row.querySelector('[data-act=publish]'); diff --git a/web/static/js/chat-widget.js b/web/static/js/chat-widget.js index 8c2e801..dd41bbf 100644 --- a/web/static/js/chat-widget.js +++ b/web/static/js/chat-widget.js @@ -25,13 +25,13 @@ if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages') panel.hidden = true; panel.innerHTML = `
- - + + Messages - - - + + +