fix: customer-based board visibility, chat widget UX, atomized Subdivide, static cache-busting

- Board visibility (§4.4): developers now see tasks for any customer with a
  consultant who pooled them, not only tasks whose consultantId is in their
  pool. Fixes a published task being invisible to its own author who is both
  consultant and developer. Adds TestBoardVisibilityIsCustomerBased.
- Chat widget: working ✕ close, distinct (non-ghost) header buttons, reliable
  conversation switching, ← Back, title ellipsis.
- Atomization: atomized/imported subtasks now expose a Subdivide button.
- Static assets: serve with ETag + Cache-Control: no-cache so JS/CSS fixes
  reach clients immediately instead of being masked by stale max-age caching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
etalon
2026-06-13 10:20:06 +02:00
parent c59aea42ef
commit 05dc91410f
6 changed files with 152 additions and 50 deletions
+53 -33
View File
@@ -33,28 +33,54 @@ 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,
CustomerIDs: customerIDs,
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,
}
@@ -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
}
@@ -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
+30 -6
View File
@@ -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] + `"`
}
+5 -1
View File
@@ -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;
+4
View File
@@ -149,6 +149,8 @@ function renderChildren(parent, kids, depth) {
</label>
<span>
<button class="btn small" data-act="edit">Edit</button>
${['imported', 'atomized'].includes(k.status)
? '<button class="btn small" data-act="subdivide" data-needs-atomizer>Subdivide</button>' : ''}
<button class="btn small" data-act="extend" data-needs-atomizer>Extend</button>
${k.status === 'atomized' ? '<button class="btn small primary" data-act="publish">Publish</button>' : ''}
<button class="btn small" data-act="archive">Archive</button>
@@ -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]');
+5 -5
View File
@@ -25,13 +25,13 @@ if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages')
panel.hidden = true;
panel.innerHTML = `
<header>
<span style="display:flex;align-items:center;gap:8px">
<button class="btn small ghost" id="cw-back" hidden aria-label="Back to conversations">←</button>
<span style="display:flex;align-items:center;gap:8px;min-width:0">
<button class="btn small" id="cw-back" hidden aria-label="Back to conversations">← Back</button>
<strong id="cw-title">Messages</strong>
</span>
<span>
<a class="btn small ghost" id="cw-full" href="/messages" aria-label="Open full messages">⤢</a>
<button class="btn small ghost" id="cw-close" aria-label="Close">×</button>
<span style="display:flex;gap:6px">
<a class="btn small" id="cw-full" href="/messages" aria-label="Open full messages" title="Open full messages">⤢</a>
<button class="btn small" id="cw-close" aria-label="Close" title="Close">✕</button>
</span>
</header>
<div class="cw-body" id="cw-body"></div>