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.AppInternalURL, []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.AppInternalURL + "/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) }