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