feat: bounty board, task lifecycle, AI work performer flow, notifications (phase 8)

- whitelist HTML sanitizer (stdlib tokenizer) with XSS vector tests
- developer board: pool-scoped visibility, customer/search/minBounty/sort
  filters, stale-task age badges, competing-claims visibility setting,
  saved filters in profile extras
- claims: request/withdraw (developer), approve/decline (consultant) with
  notifications to winners and losers; unassign/abandon back to board
- work tracking: start, sanitized comments with @mention notifications,
  time logging, submit for review
- review queue + review with per-AC checklist stored on the timeline;
  approve writes the immutable bountyAwards row (human assignees only)
- assign-to-AI: §5.2 job submission, HMAC-verified callback endpoint,
  idempotent by jobId, artifacts downloaded into GridFS, failure path
  keeps the task assigned with timeline + notification
- notifications API + bell with unread badge, dropdown, page, WS toasts
- pages: bounty board, my-tasks kanban, task detail (role-driven actions,
  review dialog, AI dialog), review queue, developer pool

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
etalon
2026-06-12 20:11:05 +02:00
parent f87b954f27
commit 70a813edfa
28 changed files with 2764 additions and 14 deletions
+358
View File
@@ -0,0 +1,358 @@
package httpx
import (
"net/http"
"strconv"
"strings"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"bountyboard/internal/chat"
"bountyboard/internal/domain"
"bountyboard/internal/files"
"bountyboard/internal/store"
"bountyboard/internal/workperform"
)
func (s *Server) routesConsultant(mux *http.ServeMux) {
con := func(h http.HandlerFunc) http.Handler { return s.authedRole("consultant", h) }
mux.Handle("POST /api/v1/tasks/{id}/approve-claim", con(s.handleApproveClaim))
mux.Handle("POST /api/v1/tasks/{id}/decline-claim", con(s.handleDeclineClaim))
mux.Handle("POST /api/v1/tasks/{id}/unassign", con(s.handleUnassign))
mux.Handle("POST /api/v1/tasks/{id}/assign-ai", con(s.handleAssignAI))
mux.Handle("POST /api/v1/tasks/{id}/review", con(s.handleReview))
mux.Handle("GET /api/v1/consultant/reviews", con(s.handleReviewQueue))
mux.Handle("GET /api/v1/consultant/pool", con(s.handleListPool))
mux.Handle("POST /api/v1/consultant/pool", con(s.handleAddPool))
mux.Handle("DELETE /api/v1/consultant/pool/{developerId}", con(s.handleRemovePool))
}
func (s *Server) handleApproveClaim(w http.ResponseWriter, r *http.Request) {
var req struct {
DeveloperID string `json:"developerId"`
}
if !decodeJSON(w, r, &req) {
return
}
t, _, ok := s.consultantTask(w, r, r.PathValue("id"))
if !ok {
return
}
if !t.HasClaimFrom(req.DeveloperID) {
writeError(w, http.StatusBadRequest, "no_claim", "that developer has not requested this task")
return
}
me := CurrentUser(r.Context())
declined := []string{}
for _, cr := range t.ClaimRequests {
if cr.DeveloperID != req.DeveloperID {
declined = append(declined, cr.DeveloperID)
}
}
err := s.store.TransitionTask(r.Context(), t, domain.StatusAssigned, me.ID, "claim_approved",
map[string]any{"developerId": req.DeveloperID},
map[string]any{
"assignee": domain.Assignee{Kind: "human", UserID: req.DeveloperID},
"claimRequests": []domain.ClaimRequest{},
})
if err != nil {
s.taskTransitionError(w, err)
return
}
s.notifyUser(r.Context(), req.DeveloperID, "claim_approved",
"You got the task: "+t.Title, "Bounty "+formatBounty(t.Bounty), "/tasks/"+t.ID)
for _, id := range declined { // §4.4: declined devs are notified
s.notifyUser(r.Context(), id, "claim_declined",
"Task went to someone else: "+t.Title, "", "/board")
}
s.Publish("board", "task.assigned", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeclineClaim(w http.ResponseWriter, r *http.Request) {
var req struct {
DeveloperID string `json:"developerId"`
}
if !decodeJSON(w, r, &req) {
return
}
t, _, ok := s.consultantTask(w, r, r.PathValue("id"))
if !ok {
return
}
if !t.HasClaimFrom(req.DeveloperID) {
writeError(w, http.StatusBadRequest, "no_claim", "that developer has not requested this task")
return
}
s.notifyUser(r.Context(), req.DeveloperID, "claim_declined",
"Assignment request declined: "+t.Title, "", "/board")
s.removeClaim(w, r, t, req.DeveloperID, CurrentUser(r.Context()).ID, "claim_declined")
}
func (s *Server) handleUnassign(w http.ResponseWriter, r *http.Request) {
t, _, ok := s.consultantTask(w, r, r.PathValue("id"))
if !ok {
return
}
prev := t.Assignee
// best-effort cancel for AI jobs
if prev != nil && prev.Kind == "ai" && prev.JobID != "" && s.performerClient != nil {
if err := s.performerClient.Cancel(r.Context(), prev.JobID); err != nil {
s.log.Warn("cancel wp job", "jobId", prev.JobID, "err", err)
}
}
err := s.store.TransitionTask(r.Context(), t, domain.StatusPublished,
CurrentUser(r.Context()).ID, "unassigned", nil, map[string]any{"assignee": nil})
if err != nil {
s.taskTransitionError(w, err)
return
}
if prev != nil && prev.Kind == "human" && prev.UserID != "" {
s.notifyUser(r.Context(), prev.UserID, "unassigned",
"You were unassigned: "+t.Title, "The task is back on the board.", "/board")
}
s.Publish("board", "task.published", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleAssignAI(w http.ResponseWriter, r *http.Request) {
var req struct {
Context workperform.JobContext `json:"context"`
}
if !decodeJSON(w, r, &req) {
return
}
t, _, ok := s.consultantTask(w, r, r.PathValue("id"))
if !ok {
return
}
if t.Status != domain.StatusPublished {
writeError(w, http.StatusConflict, "bad_status", "only published tasks can be assigned to AI")
return
}
if s.performerClient == nil {
writeError(w, http.StatusServiceUnavailable, "performer_unavailable", "work performer is not configured")
return
}
atts := make([]workperform.Attachment, 0, len(t.Attachments))
for _, a := range t.Attachments {
url := a.URL
if a.FileID != "" {
url = files.SignedURL(s.cfg.AppBaseURL, []byte(s.cfg.SessionSecret), a.FileID,
time.Now().Add(files.DefaultTokenTTL))
}
atts = append(atts, workperform.Attachment{Name: a.Name, URL: url, MimeType: a.MimeType})
}
job, err := s.performerClient.Submit(r.Context(), workperform.JobRequest{
TaskID: t.ID,
Title: t.Title,
Description: t.Description,
AcceptanceCriteria: t.AcceptanceCriteria,
Attachments: atts,
Links: t.Links,
Context: req.Context,
CallbackURL: s.cfg.AppBaseURL + "/api/v1/internal/work-results",
})
if err != nil {
s.log.Error("submit wp job", "task", t.ID, "err", err)
writeError(w, http.StatusBadGateway, "performer_error", "work performer rejected the job: "+err.Error())
return
}
err = s.store.TransitionTask(r.Context(), t, domain.StatusAssigned,
CurrentUser(r.Context()).ID, "assigned_to_ai",
map[string]any{"jobId": job.JobID},
map[string]any{"assignee": domain.Assignee{Kind: "ai", JobID: job.JobID}})
if err != nil {
// roll the job back as best we can
if cErr := s.performerClient.Cancel(r.Context(), job.JobID); cErr != nil {
s.log.Warn("cancel orphaned wp job", "jobId", job.JobID, "err", cErr)
}
s.taskTransitionError(w, err)
return
}
s.Publish("board", "task.assigned", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
writeJSON(w, http.StatusAccepted, map[string]string{"jobId": job.JobID})
}
func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
u := CurrentUser(r.Context())
consultantID := u.ID
if u.Roles.Admin {
consultantID = ""
}
customers, err := s.store.ListCustomers(r.Context(), consultantID, false)
if err != nil {
s.internalError(w, r, "list customers", err)
return
}
ids := make([]string, len(customers))
for i, c := range customers {
ids[i] = c.ID
}
tasks := []domain.Task{}
if len(ids) > 0 {
tasks, err = s.store.ListTasks(r.Context(), store.TaskFilter{
CustomerIDs: ids,
Statuses: []domain.TaskStatus{domain.StatusInReview},
Sort: "updated",
Limit: 200,
})
if err != nil {
s.internalError(w, r, "list reviews", err)
return
}
}
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks})
}
func (s *Server) handleReview(w http.ResponseWriter, r *http.Request) {
var req struct {
Decision string `json:"decision"` // approve | request_changes
Note string `json:"note"`
Checklist []struct {
Criterion string `json:"criterion"`
OK bool `json:"ok"`
} `json:"checklist"`
}
if !decodeJSON(w, r, &req) {
return
}
t, _, ok := s.consultantTask(w, r, r.PathValue("id"))
if !ok {
return
}
me := CurrentUser(r.Context())
note := chat.SanitizePlain(req.Note)
// §11.18: per-AC verdicts land on the timeline as the QA audit trail
checklist := make([]map[string]any, 0, len(req.Checklist))
for _, c := range req.Checklist {
checklist = append(checklist, map[string]any{"criterion": c.Criterion, "ok": c.OK})
}
data := map[string]any{"note": note, "checklist": checklist}
switch req.Decision {
case "approve":
err := s.store.TransitionTask(r.Context(), t, domain.StatusApproved, me.ID, "approved", data, nil)
if err != nil {
s.taskTransitionError(w, err)
return
}
// §4.5: immutable award, human assignees only (AI earns no bounty)
if t.Assignee != nil && t.Assignee.Kind == "human" && t.Assignee.UserID != "" {
award := &store.BountyAward{
TaskID: t.ID,
DeveloperID: t.Assignee.UserID,
ConsultantID: t.ConsultantID,
CustomerID: t.CustomerID,
Amount: t.Bounty,
Coefficient: t.EffortCoefficient,
}
if err := s.store.InsertAward(r.Context(), award); err != nil {
s.log.Error("insert bounty award", "task", t.ID, "err", err)
}
s.notifyUser(r.Context(), t.Assignee.UserID, "approved",
"Approved! Bounty awarded: "+formatBounty(t.Bounty), t.Title, "/tasks/"+t.ID)
}
s.metrics.Inc("tasks_approved_total", 1)
case "request_changes":
if strings.TrimSpace(note) == "" {
writeError(w, http.StatusBadRequest, "note_required", "explain what needs to change")
return
}
err := s.store.TransitionTask(r.Context(), t, domain.StatusChangesRequested, me.ID, "changes_requested", data, nil)
if err != nil {
s.taskTransitionError(w, err)
return
}
if t.Assignee != nil && t.Assignee.Kind == "human" && t.Assignee.UserID != "" {
s.notifyUser(r.Context(), t.Assignee.UserID, "changes_requested",
"Changes requested: "+t.Title, note, "/tasks/"+t.ID)
}
s.metrics.Inc("tasks_changes_requested_total", 1)
default:
writeError(w, http.StatusBadRequest, "bad_decision", "decision must be approve or request_changes")
return
}
s.Publish("board", "task.reviewed", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleListPool(w http.ResponseWriter, r *http.Request) {
me := CurrentUser(r.Context())
poolIDs, err := s.store.PoolDeveloperIDs(r.Context(), me.ID)
if err != nil {
s.internalError(w, r, "list pool", err)
return
}
inPool := map[string]bool{}
for _, id := range poolIDs {
inPool[id] = true
}
// global developer pool (§3): all enabled developers
cur, err := s.store.DB.Collection("users").Find(r.Context(),
bson.M{"roles.developer": true, "disabled": false})
if err != nil {
s.internalError(w, r, "list developers", err)
return
}
var devs []domain.User
if err := cur.All(r.Context(), &devs); err != nil {
s.internalError(w, r, "decode developers", err)
return
}
out := make([]map[string]any, 0, len(devs))
for _, d := range devs {
out = append(out, map[string]any{
"id": d.ID, "name": d.Name, "email": d.Email, "bio": d.Bio,
"avatarFileId": d.AvatarFileID, "inPool": inPool[d.ID],
})
}
writeJSON(w, http.StatusOK, map[string]any{"developers": out})
}
func (s *Server) handleAddPool(w http.ResponseWriter, r *http.Request) {
var req struct {
DeveloperID string `json:"developerId"`
}
if !decodeJSON(w, r, &req) {
return
}
dev, err := s.store.UserByID(r.Context(), req.DeveloperID)
if err != nil || !dev.Roles.Developer {
writeError(w, http.StatusBadRequest, "not_developer", "that user is not a developer")
return
}
me := CurrentUser(r.Context())
if err := s.store.AddToPool(r.Context(), me.ID, req.DeveloperID); err != nil {
if err == store.ErrDuplicate {
writeError(w, http.StatusConflict, "already_in_pool", "developer is already in your pool")
return
}
s.internalError(w, r, "add to pool", err)
return
}
s.notifyUser(r.Context(), req.DeveloperID, "pool_added",
me.Name+" added you to their developer pool",
"Their published tasks now appear on your bounty board.", "/board")
w.WriteHeader(http.StatusCreated)
}
func (s *Server) handleRemovePool(w http.ResponseWriter, r *http.Request) {
me := CurrentUser(r.Context())
if err := s.store.RemoveFromPool(r.Context(), me.ID, r.PathValue("developerId")); err != nil {
if err == store.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "developer is not in your pool")
return
}
s.internalError(w, r, "remove from pool", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func formatBounty(v float64) string {
return strconv.FormatFloat(v, 'f', -1, 64)
}