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:
+53
-33
@@ -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)))
|
mux.Handle("GET /api/v1/users/{id}/card", s.requireAuth(http.HandlerFunc(s.handleUserCard)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// boardConsultants resolves which consultants' tasks the developer sees:
|
// boardCustomers resolves the developer's visibility scope: every customer
|
||||||
// the consultants who selected them into a pool (§3).
|
// that has at least one consultant who selected this developer into a pool.
|
||||||
func (s *Server) boardConsultants(r *http.Request) ([]string, error) {
|
// Visibility is customer-based, not task-owner-based — §4.4: all consultants
|
||||||
return s.store.PoolConsultantIDs(r.Context(), CurrentUser(r.Context()).ID)
|
// 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) {
|
func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request) {
|
||||||
consultants, err := s.boardConsultants(r)
|
customers, err := s.boardCustomers(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internalError(w, r, "pool lookup", err)
|
s.internalError(w, r, "board scope", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
q := r.URL.Query()
|
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{}
|
tasks := []domain.Task{}
|
||||||
if len(consultants) > 0 {
|
if len(customerIDs) > 0 {
|
||||||
minBounty, _ := strconv.ParseFloat(q.Get("minBounty"), 64)
|
minBounty, _ := strconv.ParseFloat(q.Get("minBounty"), 64)
|
||||||
filter := store.TaskFilter{
|
filter := store.TaskFilter{
|
||||||
ConsultantIDs: consultants,
|
CustomerIDs: customerIDs,
|
||||||
Statuses: []domain.TaskStatus{domain.StatusPublished, domain.StatusClaimRequested},
|
Statuses: []domain.TaskStatus{domain.StatusPublished, domain.StatusClaimRequested},
|
||||||
Search: strings.TrimSpace(q.Get("q")),
|
Search: strings.TrimSpace(q.Get("q")),
|
||||||
MinBounty: minBounty,
|
MinBounty: minBounty,
|
||||||
CustomerID: q.Get("customerId"),
|
|
||||||
Sort: q.Get("sort"), // newest (default) | bounty
|
Sort: q.Get("sort"), // newest (default) | bounty
|
||||||
Limit: 200,
|
Limit: 200,
|
||||||
}
|
}
|
||||||
@@ -78,18 +104,12 @@ func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request) {
|
|||||||
tasks[i].ClaimRequests = mine
|
tasks[i].ClaimRequests = mine
|
||||||
}
|
}
|
||||||
|
|
||||||
// customers for the filter dropdown
|
// customers for the filter dropdown (full visibility scope)
|
||||||
customerIDs := map[string]bool{}
|
customersOut := make([]map[string]any, len(customers))
|
||||||
for _, t := range tasks {
|
for i, c := range customers {
|
||||||
customerIDs[t.CustomerID] = true
|
customersOut[i] = map[string]any{"id": c.ID, "name": c.Name}
|
||||||
}
|
}
|
||||||
customers := []map[string]any{}
|
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customers": customersOut})
|
||||||
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})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleMyTasks(w http.ResponseWriter, r *http.Request) {
|
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})
|
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks})
|
||||||
}
|
}
|
||||||
|
|
||||||
// developerTask loads a task ensuring the developer can see it via a pool
|
// developerTask loads a task ensuring the developer can see it: the task's
|
||||||
// relationship with its consultant.
|
// 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) {
|
func (s *Server) developerTask(w http.ResponseWriter, r *http.Request, id string) (*domain.Task, bool) {
|
||||||
t, err := s.store.TaskByID(r.Context(), id)
|
t, err := s.store.TaskByID(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -122,20 +143,19 @@ func (s *Server) developerTask(w http.ResponseWriter, r *http.Request, id string
|
|||||||
}
|
}
|
||||||
return nil, false
|
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) {
|
if t.IsAssignedTo(CurrentUser(r.Context()).ID) {
|
||||||
return t, true
|
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")
|
writeError(w, http.StatusForbidden, "forbidden", "this task is not on your board")
|
||||||
return nil, false
|
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) {
|
func TestDeveloperOutsidePoolSeesNothing(t *testing.T) {
|
||||||
l := newLifecycleStack(t, nil)
|
l := newLifecycleStack(t, nil)
|
||||||
base := l.ts.URL + "/api/v1/tasks/" + l.task.ID
|
base := l.ts.URL + "/api/v1/tasks/" + l.task.ID
|
||||||
|
|||||||
+31
-7
@@ -1,6 +1,8 @@
|
|||||||
package httpx
|
package httpx
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"html/template"
|
"html/template"
|
||||||
@@ -126,8 +128,19 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err) // embed layout is fixed at compile time
|
panic(err) // embed layout is fixed at compile time
|
||||||
}
|
}
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/",
|
// no-cache + a build-wide strong ETag: browsers revalidate on every
|
||||||
cacheControl(http.FileServerFS(staticFS), "public, max-age=3600")))
|
// 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) {
|
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})
|
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 {
|
// staticETag hashes every embedded static asset into one build-wide ETag.
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
func staticETag(fsys fs.FS) string {
|
||||||
w.Header().Set("Cache-Control", value)
|
h := sha256.New()
|
||||||
next.ServeHTTP(w, r)
|
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] + `"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -659,7 +659,11 @@ legend {
|
|||||||
background-image: var(--dither);
|
background-image: var(--dither);
|
||||||
background-color: var(--surface2);
|
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-body { flex: 1; overflow: auto; padding: 6px 10px; }
|
||||||
.chat-panel .cw-conv {
|
.chat-panel .cw-conv {
|
||||||
display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||||
|
|||||||
@@ -149,6 +149,8 @@ function renderChildren(parent, kids, depth) {
|
|||||||
</label>
|
</label>
|
||||||
<span>
|
<span>
|
||||||
<button class="btn small" data-act="edit">Edit</button>
|
<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>
|
<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>' : ''}
|
${k.status === 'atomized' ? '<button class="btn small primary" data-act="publish">Publish</button>' : ''}
|
||||||
<button class="btn small" data-act="archive">Archive</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));
|
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=extend]').addEventListener('click', () => openExtend(k));
|
||||||
row.querySelector('[data-act=archive]').addEventListener('click', () => archive(k));
|
row.querySelector('[data-act=archive]').addEventListener('click', () => archive(k));
|
||||||
const pub = row.querySelector('[data-act=publish]');
|
const pub = row.querySelector('[data-act=publish]');
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages')
|
|||||||
panel.hidden = true;
|
panel.hidden = true;
|
||||||
panel.innerHTML = `
|
panel.innerHTML = `
|
||||||
<header>
|
<header>
|
||||||
<span style="display:flex;align-items:center;gap:8px">
|
<span style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||||
<button class="btn small ghost" id="cw-back" hidden aria-label="Back to conversations">←</button>
|
<button class="btn small" id="cw-back" hidden aria-label="Back to conversations">← Back</button>
|
||||||
<strong id="cw-title">Messages</strong>
|
<strong id="cw-title">Messages</strong>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span style="display:flex;gap:6px">
|
||||||
<a class="btn small ghost" id="cw-full" href="/messages" aria-label="Open full messages">⤢</a>
|
<a class="btn small" id="cw-full" href="/messages" aria-label="Open full messages" title="Open full messages">⤢</a>
|
||||||
<button class="btn small ghost" id="cw-close" aria-label="Close">×</button>
|
<button class="btn small" id="cw-close" aria-label="Close" title="Close">✕</button>
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div class="cw-body" id="cw-body"></div>
|
<div class="cw-body" id="cw-body"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user