package httpx import ( "context" "errors" "net/http" "strings" "time" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo/options" "bountyboard/internal/atomize" "bountyboard/internal/domain" "bountyboard/internal/store" ) func (s *Server) routesTasks(mux *http.ServeMux) { mux.Handle("GET /api/v1/consultant/board", s.authedRole("consultant", s.handleConsultantBoard)) mux.Handle("POST /api/v1/tasks/{id}/subdivide", s.authedRole("consultant", s.handleSubdivide)) mux.Handle("POST /api/v1/tasks/{id}/extend", s.authedRole("consultant", s.handleExtend)) mux.Handle("PATCH /api/v1/tasks/{id}", s.authedRole("consultant", s.handleEditTask)) mux.Handle("POST /api/v1/tasks/{id}/publish", s.authedRole("consultant", s.handlePublishOne)) mux.Handle("POST /api/v1/tasks/publish", s.authedRole("consultant", s.handlePublishBulk)) mux.Handle("POST /api/v1/tasks/{id}/archive", s.authed(s.handleArchiveTask)) mux.Handle("POST /api/v1/tasks/archive", s.authedRole("consultant", s.handleArchiveBulk)) mux.Handle("GET /api/v1/archive", s.requireAuth(http.HandlerFunc(s.handleArchive))) mux.Handle("GET /api/v1/tasks/{id}", s.requireAuth(http.HandlerFunc(s.handleTaskDetail))) mux.Handle("GET /api/v1/service-health", s.requireAuth(http.HandlerFunc(s.handleServiceHealth))) } // handleArchive lists archived tasks scoped by role: admins see everything, // consultants see their customers' tasks, developers see tasks they were // assigned to or worked on. Optional ?q= full-text search. func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) { me := CurrentUser(r.Context()) q := bson.M{"status": domain.StatusArchived} if !me.Roles.Admin { ors := []bson.M{} if me.Roles.Consultant { cs, err := s.store.ListCustomers(r.Context(), me.ID, true) if err != nil { s.internalError(w, r, "archive customers", err) return } ids := make([]string, 0, len(cs)) for _, c := range cs { ids = append(ids, c.ID) } ors = append(ors, bson.M{"customerId": bson.M{"$in": ids}}) } if me.Roles.Developer { ors = append(ors, bson.M{"assignee.userId": me.ID}, bson.M{"claimRequests.developerId": me.ID}) } // everyone also sees archived tasks they personally acted on ors = append(ors, bson.M{"timeline.actorId": me.ID}) q["$or"] = ors } if search := strings.TrimSpace(r.URL.Query().Get("q")); search != "" { q["$text"] = bson.M{"$search": search} } cur, err := s.store.DB.Collection("tasks").Find(r.Context(), q, options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(200)) if err != nil { s.internalError(w, r, "list archive", err) return } tasks := []domain.Task{} if err := cur.All(r.Context(), &tasks); err != nil { s.internalError(w, r, "decode archive", err) return } // resolve customer names for display names := map[string]string{} for _, t := range tasks { if t.CustomerID != "" && names[t.CustomerID] == "" { if c, err := s.store.CustomerByID(r.Context(), t.CustomerID); err == nil { names[t.CustomerID] = c.Name } } } writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customerNames": names}) } // consultantTask loads a task and authorizes the current user: admins and // every consultant assigned to the task's customer may manage it (§4.4). func (s *Server) consultantTask(w http.ResponseWriter, r *http.Request, id string) (*domain.Task, *domain.Customer, 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, nil, false } c, err := s.store.CustomerByID(r.Context(), t.CustomerID) if err != nil { s.internalError(w, r, "load customer", err) return nil, nil, false } u := CurrentUser(r.Context()) if !u.Roles.Admin && !(u.Roles.Consultant && c.HasConsultant(u.ID)) { writeError(w, http.StatusForbidden, "forbidden", "you are not assigned to this customer") return nil, nil, false } return t, c, true } func (s *Server) handleConsultantBoard(w http.ResponseWriter, r *http.Request) { u := CurrentUser(r.Context()) consultantID := u.ID if u.Roles.Admin { consultantID = "" // admins see all customers } customers, err := s.store.ListCustomers(r.Context(), consultantID, false) if err != nil { s.internalError(w, r, "list customers", err) return } customerID := r.URL.Query().Get("customerId") ids := []string{} for _, c := range customers { if customerID == "" || c.ID == customerID { ids = append(ids, c.ID) } } filter := store.TaskFilter{ CustomerIDs: ids, Statuses: []domain.TaskStatus{ domain.StatusImported, domain.StatusAtomizing, domain.StatusAtomized, domain.StatusPublished, domain.StatusClaimRequested, }, Limit: 500, Sort: "updated", } if st := r.URL.Query().Get("status"); st != "" { filter.Statuses = []domain.TaskStatus{domain.TaskStatus(st)} } tasks := []domain.Task{} if len(ids) > 0 { if tasks, err = s.store.ListTasks(r.Context(), filter); err != nil { s.internalError(w, r, "list tasks", err) return } } out := make([]map[string]any, len(customers)) for i := range customers { out[i] = customerJSON(&customers[i]) } writeJSON(w, http.StatusOK, map[string]any{"customers": out, "tasks": tasks}) } func (s *Server) handleSubdivide(w http.ResponseWriter, r *http.Request) { var req struct { Note string `json:"note"` Constraints *struct { MinTasks int `json:"minTasks"` MaxTasks int `json:"maxTasks"` } `json:"constraints"` ConfirmReplace bool `json:"confirmReplace"` } if !decodeJSON(w, r, &req) { return } t, _, ok := s.consultantTask(w, r, r.PathValue("id")) if !ok { return } if t.Status != domain.StatusImported && t.Status != domain.StatusAtomized { writeError(w, http.StatusConflict, "bad_status", "only imported or atomized tasks can be subdivided (current: "+string(t.Status)+")") return } minT, maxT := 0, 0 if req.Constraints != nil { minT, maxT = req.Constraints.MinTasks, req.Constraints.MaxTasks if minT < 0 || maxT < 0 || (maxT > 0 && minT > maxT) || maxT > 20 { writeError(w, http.StatusBadRequest, "bad_constraints", "constraints must satisfy 0 <= min <= max <= 20") return } } // Re-run subdivision replaces unpublished children after confirmation // (§11.7). children, err := s.store.ListTasks(r.Context(), store.TaskFilter{ParentID: t.ID, Limit: 500}) if err != nil { s.internalError(w, r, "list children", err) return } var unpublished []string for _, c := range children { if c.Status == domain.StatusAtomized && c.Origin == domain.OriginSubdivided { unpublished = append(unpublished, c.ID) } } if len(children) > 0 && !req.ConfirmReplace { writeError(w, http.StatusConflict, "confirm_replace", "this task already has subtasks; re-running replaces the unpublished ones — confirm to proceed") return } if len(unpublished) > 0 { if _, err := s.store.DeleteTasks(r.Context(), unpublished); err != nil { s.internalError(w, r, "delete unpublished children", err) return } } fromStatus := string(t.Status) if err := s.store.TransitionTask(r.Context(), t, domain.StatusAtomizing, CurrentUser(r.Context()).ID, "subdivide_requested", map[string]any{"note": req.Note}, nil); err != nil { s.taskTransitionError(w, err) return } jobID, err := s.enqueue(r.Context(), atomize.JobKindSubdivide, atomize.SubdividePayload{ TaskID: t.ID, Note: req.Note, MinTasks: minT, MaxTasks: maxT, ActorID: CurrentUser(r.Context()).ID, FromStatus: fromStatus, }) if err != nil { s.internalError(w, r, "enqueue subdivide", err) return } s.Publish("board", "task.atomizing", map[string]any{"taskId": t.ID, "customerId": t.CustomerID}) writeJSON(w, http.StatusAccepted, map[string]string{"jobId": jobID, "status": "queued"}) } func (s *Server) handleExtend(w http.ResponseWriter, r *http.Request) { var req struct { Note string `json:"note"` } if !decodeJSON(w, r, &req) { return } if strings.TrimSpace(req.Note) == "" { writeError(w, http.StatusBadRequest, "note_required", "extension requires a note (§1.1)") return } t, _, ok := s.consultantTask(w, r, r.PathValue("id")) if !ok { return } if t.Status != domain.StatusImported && t.Status != domain.StatusAtomized { writeError(w, http.StatusConflict, "bad_status", "only imported or atomized tasks can be extended (current: "+string(t.Status)+")") return } jobID, err := s.enqueue(r.Context(), atomize.JobKindExtend, atomize.ExtendPayload{ TaskID: t.ID, Note: req.Note, ActorID: CurrentUser(r.Context()).ID, }) if err != nil { s.internalError(w, r, "enqueue extend", err) return } writeJSON(w, http.StatusAccepted, map[string]string{"jobId": jobID, "status": "queued"}) } func (s *Server) handleEditTask(w http.ResponseWriter, r *http.Request) { var req struct { Version *int64 `json:"version"` Title *string `json:"title"` Description *string `json:"description"` AcceptanceCriteria *[]string `json:"acceptanceCriteria"` Links *[]string `json:"links"` EffortCoefficient *float64 `json:"effortCoefficient"` Budget *float64 `json:"budget"` CascadeBudget bool `json:"cascadeBudget"` } if !decodeJSON(w, r, &req) { return } t, _, ok := s.consultantTask(w, r, r.PathValue("id")) if !ok { return } // §6.1: bounty is frozen once approved; archived tasks are immutable. if t.Status == domain.StatusApproved || t.Status == domain.StatusArchived { writeError(w, http.StatusConflict, "immutable", "approved/archived tasks cannot be edited") return } set := bson.M{} coeff, budget := t.EffortCoefficient, t.Budget if req.Title != nil && strings.TrimSpace(*req.Title) != "" { set["title"] = strings.TrimSpace(*req.Title) } if req.Description != nil { set["description"] = *req.Description } if req.AcceptanceCriteria != nil { set["acceptanceCriteria"] = *req.AcceptanceCriteria } if req.Links != nil { set["links"] = *req.Links } if req.EffortCoefficient != nil { coeff = *req.EffortCoefficient maxCoeff := 1.0 if t.Origin == domain.OriginExtended { maxCoeff = 2.0 // §5.1: extensions may exceed 1 } if coeff <= 0 || coeff > maxCoeff { writeError(w, http.StatusBadRequest, "bad_coefficient", "effort coefficient out of range") return } set["effortCoefficient"] = coeff } if req.Budget != nil { budget = *req.Budget if budget < 0 { writeError(w, http.StatusBadRequest, "bad_budget", "budget must be >= 0") return } set["budget"] = budget } if len(set) == 0 { writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields") return } set["bounty"] = domain.ComputeBounty(coeff, budget) version := t.Version if req.Version != nil { version = *req.Version } err := store.UpdateVersioned(r.Context(), s.store.DB.Collection("tasks"), t.ID, version, bson.M{ "$set": set, "$push": bson.M{"timeline": domain.TimelineEntry{ At: time.Now().UTC(), ActorID: CurrentUser(r.Context()).ID, Event: "edited", }}, }) if err != nil { if errors.Is(err, store.ErrVersionConflict) { writeError(w, http.StatusConflict, "conflict", "task was modified concurrently — reload") return } s.internalError(w, r, "edit task", err) return } // Optional budget cascade to descendants whose bounty is not yet frozen. if req.Budget != nil && req.CascadeBudget { if err := s.cascadeBudget(r.Context(), t.ID, budget); err != nil { s.log.Warn("cascade budget", "task", t.ID, "err", err) } } fresh, err := s.store.TaskByID(r.Context(), t.ID) if err != nil { s.internalError(w, r, "reload task", err) return } s.Publish("board", "task.updated", map[string]any{"taskId": t.ID, "customerId": t.CustomerID}) writeJSON(w, http.StatusOK, map[string]any{"task": fresh}) } // cascadeBudget walks descendants breadth-first updating budget + bounty for // tasks that are not approved/archived. func (s *Server) cascadeBudget(ctx context.Context, rootID string, budget float64) error { frontier := []string{rootID} for len(frontier) > 0 { children, err := s.store.ListTasks(ctx, store.TaskFilter{ParentID: frontier[0], Limit: 500}) if err != nil { return err } frontier = frontier[1:] for _, c := range children { if c.Status == domain.StatusApproved || c.Status == domain.StatusArchived { continue } _, err := s.store.DB.Collection("tasks").UpdateOne(ctx, bson.M{"_id": c.ID}, bson.M{"$set": bson.M{ "budget": budget, "bounty": domain.ComputeBounty(c.EffortCoefficient, budget), "updatedAt": time.Now().UTC(), }, "$inc": bson.M{"version": 1}}) if err != nil { return err } frontier = append(frontier, c.ID) } } return nil } func (s *Server) publishTask(ctx context.Context, t *domain.Task, actorID string) error { return s.store.TransitionTask(ctx, t, domain.StatusPublished, actorID, "published", nil, map[string]any{"publishedAt": time.Now().UTC()}) } func (s *Server) handlePublishOne(w http.ResponseWriter, r *http.Request) { t, _, ok := s.consultantTask(w, r, r.PathValue("id")) if !ok { return } if err := s.publishTask(r.Context(), t, CurrentUser(r.Context()).ID); err != nil { s.taskTransitionError(w, err) return } s.Publish("board", "task.published", map[string]any{"taskId": t.ID, "customerId": t.CustomerID}) fresh, _ := s.store.TaskByID(r.Context(), t.ID) writeJSON(w, http.StatusOK, map[string]any{"task": fresh}) } func (s *Server) handlePublishBulk(w http.ResponseWriter, r *http.Request) { var req struct { IDs []string `json:"ids"` } if !decodeJSON(w, r, &req) { return } if len(req.IDs) == 0 || len(req.IDs) > 100 { writeError(w, http.StatusBadRequest, "bad_request", "ids must contain 1..100 entries") return } published := []string{} failed := map[string]string{} for _, id := range req.IDs { t, err := s.store.TaskByID(r.Context(), id) if err != nil { failed[id] = "not found" continue } c, err := s.store.CustomerByID(r.Context(), t.CustomerID) if err != nil { failed[id] = "customer missing" continue } u := CurrentUser(r.Context()) if !u.Roles.Admin && !c.HasConsultant(u.ID) { failed[id] = "not your customer" continue } if err := s.publishTask(r.Context(), t, u.ID); err != nil { failed[id] = err.Error() continue } published = append(published, id) s.Publish("board", "task.published", map[string]any{"taskId": id, "customerId": t.CustomerID}) } writeJSON(w, http.StatusOK, map[string]any{"published": published, "failed": failed}) } func (s *Server) handleArchiveTask(w http.ResponseWriter, r *http.Request) { u := CurrentUser(r.Context()) if !u.Roles.Admin && !u.Roles.Consultant { writeError(w, http.StatusForbidden, "forbidden", "requires consultant or admin role") return } t, _, ok := s.consultantTask(w, r, r.PathValue("id")) if !ok { return } if err := s.store.TransitionTask(r.Context(), t, domain.StatusArchived, u.ID, "archived", nil, nil); err != nil { s.taskTransitionError(w, err) return } s.Publish("board", "task.archived", map[string]any{"taskId": t.ID, "customerId": t.CustomerID}) w.WriteHeader(http.StatusNoContent) } // handleArchiveBulk implements §11.9 bulk archive. func (s *Server) handleArchiveBulk(w http.ResponseWriter, r *http.Request) { var req struct { IDs []string `json:"ids"` } if !decodeJSON(w, r, &req) { return } if len(req.IDs) == 0 || len(req.IDs) > 100 { writeError(w, http.StatusBadRequest, "bad_request", "ids must contain 1..100 entries") return } u := CurrentUser(r.Context()) archived := []string{} failed := map[string]string{} for _, id := range req.IDs { t, err := s.store.TaskByID(r.Context(), id) if err != nil { failed[id] = "not found" continue } c, err := s.store.CustomerByID(r.Context(), t.CustomerID) if err != nil || (!u.Roles.Admin && !c.HasConsultant(u.ID)) { failed[id] = "not your customer" continue } if err := s.store.TransitionTask(r.Context(), t, domain.StatusArchived, u.ID, "archived", nil, nil); err != nil { failed[id] = err.Error() continue } archived = append(archived, id) } if len(archived) > 0 { s.Publish("board", "task.archived_bulk", map[string]any{"taskIds": archived}) } writeJSON(w, http.StatusOK, map[string]any{"archived": archived, "failed": failed}) } // handleTaskDetail is role-scoped: admins and customer consultants always; // developers when they are the assignee, have a claim, or the task is on the // public board (published/claim_requested). func (s *Server) handleTaskDetail(w http.ResponseWriter, r *http.Request) { t, err := s.store.TaskByID(r.Context(), r.PathValue("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 } u := CurrentUser(r.Context()) allowed := u.Roles.Admin if !allowed && u.Roles.Consultant { if c, err := s.store.CustomerByID(r.Context(), t.CustomerID); err == nil && c.HasConsultant(u.ID) { allowed = true } } if !allowed && u.Roles.Developer { allowed = t.IsAssignedTo(u.ID) || t.HasClaimFrom(u.ID) || t.Status == domain.StatusPublished || t.Status == domain.StatusClaimRequested } if !allowed { writeError(w, http.StatusForbidden, "forbidden", "no access to this task") return } writeJSON(w, http.StatusOK, map[string]any{"task": t}) } // handleServiceHealth powers UI gating (§11.16): disable Subdivide/Extend // when the atomizer is down, independently of the work performer. func (s *Server) handleServiceHealth(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second) defer cancel() atom := s.probeExternal(ctx, "atomizer", s.effectiveAtomizerBaseURL(ctx)) perf := s.probeExternal(ctx, "work-performer", s.cfg.WorkPerformerBaseURL) if s.atomizer != nil { atom.Breaker = s.atomizer.BreakerState() } if s.performer != nil { perf.Breaker = s.performer.BreakerState() } writeJSON(w, http.StatusOK, map[string]any{"atomizer": atom, "workPerformer": perf}) } func (s *Server) taskTransitionError(w http.ResponseWriter, err error) { var bad *domain.ErrBadTransition switch { case errors.As(err, &bad): writeError(w, http.StatusConflict, "bad_status", bad.Error()) case errors.Is(err, store.ErrVersionConflict): writeError(w, http.StatusConflict, "conflict", "task was modified concurrently — reload") case errors.Is(err, store.ErrNotFound): writeError(w, http.StatusNotFound, "not_found", "task not found") default: writeError(w, http.StatusInternalServerError, "internal", "internal server error") } }