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:
+58
-38
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user