Files
BountyBoard/internal/http/board.go
T
etalon 70cb664246 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>
2026-06-13 11:54:44 +02:00

524 lines
17 KiB
Go

package httpx
import (
"context"
"errors"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"bountyboard/internal/chat"
"bountyboard/internal/domain"
"bountyboard/internal/store"
"bountyboard/internal/ulid"
)
func (s *Server) routesBoard(mux *http.ServeMux) {
dev := func(h http.HandlerFunc) http.Handler { return s.authedRole("developer", h) }
// 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))
mux.Handle("POST /api/v1/tasks/{id}/start", dev(s.handleStartTask))
mux.Handle("POST /api/v1/tasks/{id}/submit-review", dev(s.handleSubmitReview))
mux.Handle("POST /api/v1/tasks/{id}/abandon", dev(s.handleAbandonTask))
mux.Handle("POST /api/v1/tasks/{id}/comments", s.authed(s.handleAddComment))
mux.Handle("POST /api/v1/tasks/{id}/time", dev(s.handleLogTime))
mux.Handle("GET /api/v1/notifications", s.requireAuth(http.HandlerFunc(s.handleListNotifications)))
mux.Handle("POST /api/v1/notifications/read", s.authed(s.handleReadNotifications))
mux.Handle("GET /api/v1/users/{id}/card", s.requireAuth(http.HandlerFunc(s.handleUserCard)))
}
// 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) {
me := CurrentUser(r.Context())
seen := map[string]bool{}
out := []domain.Customer{}
add := func(customers []domain.Customer) {
for _, c := range customers {
if !seen[c.ID] {
seen[c.ID] = true
out = append(out, c)
}
}
}
// 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
}
func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request) {
customers, err := s.boardCustomers(r)
if err != nil {
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(customerIDs) > 0 {
minBounty, _ := strconv.ParseFloat(q.Get("minBounty"), 64)
filter := store.TaskFilter{
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)
return
}
}
// §10: optionally hide competing claim requests from other developers.
settings, err := s.store.GetSettings(r.Context())
hideCompeting := err == nil && settings.HideCompetingClaims
me := CurrentUser(r.Context()).ID
for i := range tasks {
mine := []domain.ClaimRequest{}
for _, cr := range tasks[i].ClaimRequests {
if cr.DeveloperID == me || !hideCompeting {
mine = append(mine, cr)
}
}
tasks[i].ClaimRequests = mine
}
// 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}
}
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customers": customersOut})
}
func (s *Server) handleMyTasks(w http.ResponseWriter, r *http.Request) {
me := CurrentUser(r.Context()).ID
tasks, err := s.store.ListTasks(r.Context(), store.TaskFilter{
AssigneeID: me,
Statuses: []domain.TaskStatus{
domain.StatusAssigned, domain.StatusInProgress, domain.StatusInReview,
domain.StatusChangesRequested, domain.StatusApproved,
},
Sort: "updated",
Limit: 200,
})
if err != nil {
s.internalError(w, r, "list my tasks", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks})
}
// 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 {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "task not found")
} else {
s.internalError(w, r, "load task", err)
}
return nil, false
}
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
}
func (s *Server) handleClaim(w http.ResponseWriter, r *http.Request) {
var req struct {
Note string `json:"note"`
}
if !decodeJSON(w, r, &req) {
return
}
t, ok := s.developerTask(w, r, r.PathValue("id"))
if !ok {
return
}
me := CurrentUser(r.Context())
if t.HasClaimFrom(me.ID) {
writeError(w, http.StatusConflict, "already_requested", "you already requested this task")
return
}
claim := domain.ClaimRequest{DeveloperID: me.ID, Note: req.Note, At: time.Now().UTC()}
switch t.Status {
case domain.StatusPublished:
err := s.store.TransitionTask(r.Context(), t, domain.StatusClaimRequested,
me.ID, "claim_requested", map[string]any{"note": req.Note},
map[string]any{"claimRequests": []domain.ClaimRequest{claim}})
if err != nil {
s.taskTransitionError(w, err)
return
}
case domain.StatusClaimRequested:
// additional developers may pile on (visible per settings)
err := store.UpdateVersioned(r.Context(), s.store.DB.Collection("tasks"), t.ID, t.Version, bson.M{
"$push": bson.M{
"claimRequests": claim,
"timeline": domain.TimelineEntry{
At: time.Now().UTC(), ActorID: me.ID, Event: "claim_requested",
Data: map[string]any{"note": req.Note},
},
},
})
if err != nil {
s.taskTransitionError(w, err)
return
}
default:
writeError(w, http.StatusConflict, "bad_status", "task is not open for claims")
return
}
s.notifyUser(r.Context(), t.ConsultantID, "claim_requested",
"Assignment requested: "+t.Title, me.Name+" wants this task.", "/tasks/"+t.ID)
s.Publish("board", "task.claimed", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleWithdrawClaim(w http.ResponseWriter, r *http.Request) {
t, ok := s.developerTask(w, r, r.PathValue("id"))
if !ok {
return
}
me := CurrentUser(r.Context())
if !t.HasClaimFrom(me.ID) {
writeError(w, http.StatusConflict, "no_claim", "you have no claim on this task")
return
}
s.removeClaim(w, r, t, me.ID, me.ID, "claim_withdrawn")
}
// removeClaim drops one developer's claim; the task returns to published
// when no claims remain (§4.4).
func (s *Server) removeClaim(w http.ResponseWriter, r *http.Request, t *domain.Task,
developerID, actorID, event string) {
remaining := []domain.ClaimRequest{}
for _, cr := range t.ClaimRequests {
if cr.DeveloperID != developerID {
remaining = append(remaining, cr)
}
}
update := bson.M{
"$set": bson.M{"claimRequests": remaining},
"$push": bson.M{"timeline": domain.TimelineEntry{
At: time.Now().UTC(), ActorID: actorID, Event: event,
Data: map[string]any{"developerId": developerID},
}},
}
if len(remaining) == 0 && t.Status == domain.StatusClaimRequested {
if err := domain.Transition(t.Status, domain.StatusPublished); err != nil {
s.taskTransitionError(w, err)
return
}
update["$set"].(bson.M)["status"] = domain.StatusPublished
}
if err := store.UpdateVersioned(r.Context(), s.store.DB.Collection("tasks"), t.ID, t.Version, update); err != nil {
s.taskTransitionError(w, err)
return
}
s.Publish("board", "task.claim_removed", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleStartTask(w http.ResponseWriter, r *http.Request) {
t, ok := s.developerTask(w, r, r.PathValue("id"))
if !ok {
return
}
me := CurrentUser(r.Context())
if !t.IsAssignedTo(me.ID) {
writeError(w, http.StatusForbidden, "not_assignee", "only the assignee can start work")
return
}
// assigned → in_progress, and changes_requested → in_progress (resume)
if err := s.store.TransitionTask(r.Context(), t, domain.StatusInProgress,
me.ID, "work_started", nil, nil); err != nil {
s.taskTransitionError(w, err)
return
}
s.Publish("board", "task.updated", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleSubmitReview(w http.ResponseWriter, r *http.Request) {
t, ok := s.developerTask(w, r, r.PathValue("id"))
if !ok {
return
}
me := CurrentUser(r.Context())
if !t.IsAssignedTo(me.ID) {
writeError(w, http.StatusForbidden, "not_assignee", "only the assignee can submit for review")
return
}
if err := s.store.TransitionTask(r.Context(), t, domain.StatusInReview,
me.ID, "submitted_for_review", nil, nil); err != nil {
s.taskTransitionError(w, err)
return
}
s.notifyUser(r.Context(), t.ConsultantID, "review_requested",
"Ready for review: "+t.Title, me.Name+" submitted this task.", "/tasks/"+t.ID)
s.Publish("board", "task.updated", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleAbandonTask(w http.ResponseWriter, r *http.Request) {
t, ok := s.developerTask(w, r, r.PathValue("id"))
if !ok {
return
}
me := CurrentUser(r.Context())
if !t.IsAssignedTo(me.ID) {
writeError(w, http.StatusForbidden, "not_assignee", "only the assignee can abandon the task")
return
}
if err := s.store.TransitionTask(r.Context(), t, domain.StatusPublished,
me.ID, "abandoned", nil, map[string]any{"assignee": nil}); err != nil {
s.taskTransitionError(w, err)
return
}
s.notifyUser(r.Context(), t.ConsultantID, "task_abandoned",
"Task abandoned: "+t.Title, me.Name+" returned this task to the board.", "/tasks/"+t.ID)
s.Publish("board", "task.published", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
w.WriteHeader(http.StatusNoContent)
}
var mentionRe = regexp.MustCompile(`@([\w.+-]+@[\w.-]+\.\w+|\w+)`)
func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) {
var req struct {
Body string `json:"body"`
}
if !decodeJSON(w, r, &req) {
return
}
body := chat.SanitizeHTML(req.Body)
if strings.TrimSpace(chat.SanitizePlain(body)) == "" {
writeError(w, http.StatusBadRequest, "empty_comment", "comment cannot be empty")
return
}
me := CurrentUser(r.Context())
t, err := s.store.TaskByID(r.Context(), r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "task not found")
return
}
// commenters: consultants of the customer, the assignee, claimers
canComment := me.Roles.Admin || t.IsAssignedTo(me.ID) || t.HasClaimFrom(me.ID)
if !canComment && me.Roles.Consultant {
if c, err := s.store.CustomerByID(r.Context(), t.CustomerID); err == nil && c.HasConsultant(me.ID) {
canComment = true
}
}
if !canComment {
writeError(w, http.StatusForbidden, "forbidden", "no access to comment on this task")
return
}
comment := domain.Comment{ID: ulid.New(), AuthorID: me.ID, Body: body, At: time.Now().UTC()}
_, err = s.store.DB.Collection("tasks").UpdateOne(r.Context(), bson.M{"_id": t.ID}, bson.M{
"$push": bson.M{"comments": comment},
"$set": bson.M{"updatedAt": time.Now().UTC()},
"$inc": bson.M{"version": 1},
})
if err != nil {
s.internalError(w, r, "add comment", err)
return
}
s.notifyMentions(r, t, me, body)
// always tell the "other side"
if t.IsAssignedTo(me.ID) || t.HasClaimFrom(me.ID) {
s.notifyUser(r.Context(), t.ConsultantID, "comment",
"New comment on "+t.Title, me.Name+" commented.", "/tasks/"+t.ID)
} else if t.Assignee != nil && t.Assignee.Kind == "human" && t.Assignee.UserID != me.ID {
s.notifyUser(r.Context(), t.Assignee.UserID, "comment",
"New comment on "+t.Title, me.Name+" commented.", "/tasks/"+t.ID)
}
s.Publish("board", "task.comment", map[string]any{"taskId": t.ID})
writeJSON(w, http.StatusCreated, map[string]any{"comment": comment})
}
// notifyMentions resolves @name / @email tokens against task participants
// (§11.3).
func (s *Server) notifyMentions(r *http.Request, t *domain.Task, author *domain.User, body string) {
plain := chat.SanitizePlain(body)
matches := mentionRe.FindAllStringSubmatch(plain, 10)
if len(matches) == 0 {
return
}
// candidate participants
ids := map[string]bool{t.ConsultantID: true}
if t.Assignee != nil && t.Assignee.UserID != "" {
ids[t.Assignee.UserID] = true
}
for _, cr := range t.ClaimRequests {
ids[cr.DeveloperID] = true
}
for _, c := range t.Comments {
ids[c.AuthorID] = true
}
notified := map[string]bool{}
for _, m := range matches {
token := strings.ToLower(m[1])
for id := range ids {
if id == author.ID || notified[id] {
continue
}
u, err := s.store.UserByID(r.Context(), id)
if err != nil {
continue
}
first := strings.ToLower(strings.SplitN(u.Name, " ", 2)[0])
if token == strings.ToLower(u.Email) || token == first {
notified[id] = true
s.notifyUser(r.Context(), id, "mention",
author.Name+" mentioned you", "On task: "+t.Title, "/tasks/"+t.ID)
}
}
}
}
func (s *Server) handleLogTime(w http.ResponseWriter, r *http.Request) {
var req struct {
Minutes int `json:"minutes"`
Note string `json:"note"`
}
if !decodeJSON(w, r, &req) {
return
}
if req.Minutes <= 0 || req.Minutes > 24*60 {
writeError(w, http.StatusBadRequest, "bad_minutes", "minutes must be 1..1440")
return
}
t, ok := s.developerTask(w, r, r.PathValue("id"))
if !ok {
return
}
me := CurrentUser(r.Context())
if !t.IsAssignedTo(me.ID) {
writeError(w, http.StatusForbidden, "not_assignee", "only the assignee can log time")
return
}
entry := domain.TimeLogEntry{DeveloperID: me.ID, Minutes: req.Minutes,
Note: req.Note, At: time.Now().UTC()}
_, err := s.store.DB.Collection("tasks").UpdateOne(r.Context(), bson.M{"_id": t.ID}, bson.M{
"$push": bson.M{"timeLog": entry},
"$set": bson.M{"updatedAt": time.Now().UTC()},
"$inc": bson.M{"version": 1},
})
if err != nil {
s.internalError(w, r, "log time", err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"entry": entry})
}
func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
me := CurrentUser(r.Context()).ID
list, err := s.store.ListNotifications(r.Context(), me,
q.Get("unread") == "true", q.Get("cursor"), atoiDefault(q.Get("limit"), 30))
if err != nil {
s.internalError(w, r, "list notifications", err)
return
}
unread, err := s.store.CountUnreadNotifications(r.Context(), me)
if err != nil {
s.internalError(w, r, "count unread", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notifications": list, "unread": unread})
}
func (s *Server) handleReadNotifications(w http.ResponseWriter, r *http.Request) {
var req struct {
IDs []string `json:"ids"` // empty = mark all read
}
if !decodeJSON(w, r, &req) {
return
}
n, err := s.store.MarkNotificationsRead(r.Context(), CurrentUser(r.Context()).ID, req.IDs)
if err != nil {
s.internalError(w, r, "mark read", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"marked": n})
}
// handleUserCard serves the public profile hover card (§11.13).
func (s *Server) handleUserCard(w http.ResponseWriter, r *http.Request) {
u, err := s.store.UserByID(r.Context(), r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "user not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{"card": map[string]any{
"id": u.ID, "name": u.Name, "bio": u.Bio, "roles": u.Roles,
"avatarFileId": u.AvatarFileID, "contact": u.Contact, "extra": u.Extra,
}})
}
// notifyUser inserts an in-app notification and pushes it live.
func (s *Server) notifyUser(ctx context.Context, userID, kind, title, body, link string) {
n := &store.Notification{UserID: userID, Kind: kind, Title: title, Body: body, Link: link}
if err := s.store.InsertNotification(ctx, n); err != nil {
s.log.Warn("insert notification", "err", err)
return
}
s.Publish("notifications", "notification", n)
}