70a813edfa
- 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>
487 lines
16 KiB
Go
487 lines
16 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) }
|
|
mux.Handle("GET /api/v1/board", s.requireAuth(s.requireRole("developer", 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)))
|
|
}
|
|
|
|
// boardConsultants resolves which consultants' tasks the developer sees:
|
|
// the consultants who selected them into a pool (§3).
|
|
func (s *Server) boardConsultants(r *http.Request) ([]string, error) {
|
|
return s.store.PoolConsultantIDs(r.Context(), CurrentUser(r.Context()).ID)
|
|
}
|
|
|
|
func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request) {
|
|
consultants, err := s.boardConsultants(r)
|
|
if err != nil {
|
|
s.internalError(w, r, "pool lookup", err)
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
tasks := []domain.Task{}
|
|
if len(consultants) > 0 {
|
|
minBounty, _ := strconv.ParseFloat(q.Get("minBounty"), 64)
|
|
filter := store.TaskFilter{
|
|
ConsultantIDs: consultants,
|
|
Statuses: []domain.TaskStatus{domain.StatusPublished, domain.StatusClaimRequested},
|
|
Search: strings.TrimSpace(q.Get("q")),
|
|
MinBounty: minBounty,
|
|
CustomerID: q.Get("customerId"),
|
|
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
|
|
customerIDs := map[string]bool{}
|
|
for _, t := range tasks {
|
|
customerIDs[t.CustomerID] = true
|
|
}
|
|
customers := []map[string]any{}
|
|
for id := range customerIDs {
|
|
if c, err := s.store.CustomerByID(r.Context(), id); err == nil {
|
|
customers = append(customers, map[string]any{"id": c.ID, "name": c.Name})
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customers": customers})
|
|
}
|
|
|
|
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 via a pool
|
|
// relationship with its consultant.
|
|
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
|
|
}
|
|
consultants, err := s.boardConsultants(r)
|
|
if err != nil {
|
|
s.internalError(w, r, "pool lookup", err)
|
|
return nil, false
|
|
}
|
|
for _, cid := range consultants {
|
|
if cid == t.ConsultantID {
|
|
return t, true
|
|
}
|
|
}
|
|
// assignees keep access even if removed from the pool mid-flight
|
|
if t.IsAssignedTo(CurrentUser(r.Context()).ID) {
|
|
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)
|
|
}
|