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) }