feat: @-mention autocomplete; consultant bounty-board access; clickable atomization tasks

- @-mentions: typing "@" in task comments, the messages composer and the chat
  widget now opens a user picker (new shared mention.js) that inserts "@Name ".
  Backed by the existing /api/v1/users search; menus de-dupe per field.
- Consultants can now open the bounty board (read-only): nav link + page/API
  access extended to consultants, board visibility resolves their assigned
  customers, and cards show "Open" instead of claim actions for non-developers.
- Atomization board task titles (roots and subtasks) link to /tasks/{id} so
  consultants can reach the detail view (comments, assignment, timeline).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
etalon
2026-06-13 11:54:44 +02:00
parent b4e919502f
commit 70cb664246
10 changed files with 198 additions and 17 deletions
+27 -10
View File
@@ -19,7 +19,9 @@ import (
func (s *Server) routesBoard(mux *http.ServeMux) {
dev := func(h http.HandlerFunc) http.Handler { return s.authedRole("developer", h) }
mux.Handle("GET /api/v1/board", s.requireAuth(s.requireRole("developer", http.HandlerFunc(s.handleBoard))))
// Developers and consultants can both read the board (consultants browse to
// open task detail; claim/start/etc. below stay developer-only).
mux.Handle("GET /api/v1/board", s.requireAuth(http.HandlerFunc(s.handleBoard)))
mux.Handle("GET /api/v1/my-tasks", s.requireAuth(s.requireRole("developer", http.HandlerFunc(s.handleMyTasks))))
mux.Handle("POST /api/v1/tasks/{id}/claim", dev(s.handleClaim))
mux.Handle("POST /api/v1/tasks/{id}/claim/withdraw", dev(s.handleWithdrawClaim))
@@ -39,17 +41,10 @@ func (s *Server) routesBoard(mux *http.ServeMux) {
// 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
}
me := CurrentUser(r.Context())
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
}
add := func(customers []domain.Customer) {
for _, c := range customers {
if !seen[c.ID] {
seen[c.ID] = true
@@ -57,6 +52,28 @@ func (s *Server) boardCustomers(r *http.Request) ([]domain.Customer, error) {
}
}
}
// Developers: every customer that has a consultant who pooled them.
if me.Roles.Developer {
consultants, err := s.store.PoolConsultantIDs(r.Context(), me.ID)
if err != nil {
return nil, err
}
for _, cid := range consultants {
customers, err := s.store.ListCustomers(r.Context(), cid, false)
if err != nil {
return nil, err
}
add(customers)
}
}
// Consultants: every customer they are assigned to.
if me.Roles.Consultant {
customers, err := s.store.ListCustomers(r.Context(), me.ID, false)
if err != nil {
return nil, err
}
add(customers)
}
return out, nil
}