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:
etalon
2026-06-12 20:11:05 +02:00
parent f87b954f27
commit 70a813edfa
28 changed files with 2764 additions and 14 deletions
+1
View File
@@ -8,3 +8,4 @@
- Phase 5 (admin): customer CRUD wizard with per-system encrypted credentials + test connection (jira/ado/youtrack/demo connectors), user management (roles, disable w/ session revoke, force-reset, delete, self-lockout guards), runtime settings doc, audit log on every privileged mutation + list API, service-status panel (mongo/atomizer/WP health + latency, late-bound breaker/sync/jobs providers), tabbed admin UI - tests green. Fixed mongod WT_PANIC: container nofile ulimit was 1024, raised to 64000 in compose. - Phase 5 (admin): customer CRUD wizard with per-system encrypted credentials + test connection (jira/ado/youtrack/demo connectors), user management (roles, disable w/ session revoke, force-reset, delete, self-lockout guards), runtime settings doc, audit log on every privileged mutation + list API, service-status panel (mongo/atomizer/WP health + latency, late-bound breaker/sync/jobs providers), tabbed admin UI - tests green. Fixed mongod WT_PANIC: container nofile ulimit was 1024, raised to 64000 in compose.
- Phase 6 (sync workers): per-customer pollers with reconcile loop (start/stop/interval changes), §5.3 idempotent upsert keyed (system,key,customerId) w/ content-hash change detection, upstream edits refresh only while imported (timeline+notification after), attachment caching to GridFS, orphan flagging (never deletes), admin sync-now trigger + worker statuses, default budget prefill — demo-type integration tests green. - Phase 6 (sync workers): per-customer pollers with reconcile loop (start/stop/interval changes), §5.3 idempotent upsert keyed (system,key,customerId) w/ content-hash change detection, upstream edits refresh only while imported (timeline+notification after), attachment caching to GridFS, orphan flagging (never deletes), admin sync-now trigger + worker statuses, default budget prefill — demo-type integration tests green.
- Phase 7 (atomization): circuit breaker (3 fails→open, 60s half-open probe) + retrying bearer JSON client shared by both external services, atomizer client w/ §5.1 coefficient normalization (±0.001 ok, ≤0.05 renormalized, else 502-class), persisted jobs queue (panic-safe, backoff ×3, stale requeue, restart-safe), subdivide/extend endpoints (202 async, re-run replaces unpublished children after confirm), task editing w/ bounty recompute + optional budget cascade, publish single/bulk, WS hub (origin check, 30s heartbeats) + live board, consultant atomization board UI (tree, sliders, sum indicator, modals, shimmer, health gating) — unit + integration green. Fixed tasks unique index: sparse→partial (sparse compound matched every task via customerId). - Phase 7 (atomization): circuit breaker (3 fails→open, 60s half-open probe) + retrying bearer JSON client shared by both external services, atomizer client w/ §5.1 coefficient normalization (±0.001 ok, ≤0.05 renormalized, else 502-class), persisted jobs queue (panic-safe, backoff ×3, stale requeue, restart-safe), subdivide/extend endpoints (202 async, re-run replaces unpublished children after confirm), task editing w/ bounty recompute + optional budget cascade, publish single/bulk, WS hub (origin check, 30s heartbeats) + live board, consultant atomization board UI (tree, sliders, sum indicator, modals, shimmer, health gating) — unit + integration green. Fixed tasks unique index: sparse→partial (sparse compound matched every task via customerId).
- Phase 8 (bounty board + lifecycle): developer board (pool-scoped, filters/search/sort, stale age badges, hide-competing-claims setting, saved filters), claim/withdraw/decline/approve, assign-to-AI via §5.2 client + HMAC-verified idempotent callback w/ artifact ingestion into GridFS, start/submit/abandon/unassign, comments (sanitized, @mentions→notifications), time log, review queue + per-AC checklist on timeline, immutable bountyAwards on approve (humans only), notifications center + bell + WS toasts, my-tasks kanban, task detail page, pool management UI, server-side HTML sanitizer w/ XSS vector tests — full lifecycle integration tests green.
+7
View File
@@ -24,6 +24,7 @@ import (
"bountyboard/internal/metrics" "bountyboard/internal/metrics"
"bountyboard/internal/store" "bountyboard/internal/store"
syncpkg "bountyboard/internal/sync" syncpkg "bountyboard/internal/sync"
"bountyboard/internal/workperform"
"bountyboard/internal/ws" "bountyboard/internal/ws"
) )
@@ -98,6 +99,12 @@ func run() error {
srv.SetAtomizerInfo(atomClient) srv.SetAtomizerInfo(atomClient)
srv.AddReadinessCheck(httpx.ReadinessCheck{Name: "atomizer", Probe: atomClient.Healthy}) srv.AddReadinessCheck(httpx.ReadinessCheck{Name: "atomizer", Probe: atomClient.Healthy})
// Work Performer client — fully independent service (§5).
wpClient := workperform.New(func() string { return cfg.WorkPerformerBaseURL },
cfg.WorkPerformerToken, cfg.WorkPerformerHTTPTimeout)
srv.SetPerformerClient(wpClient)
srv.AddReadinessCheck(httpx.ReadinessCheck{Name: "work-performer", Probe: wpClient.Healthy})
// Background job queue + atomization handlers. // Background job queue + atomization handlers.
runner := jobs.NewRunner(st, log, reg, 4) runner := jobs.NewRunner(st, log, reg, 4)
atomSvc := atomize.NewService(st, atomClient, log, cfg.AppBaseURL, atomSvc := atomize.NewService(st, atomClient, log, cfg.AppBaseURL,
+168
View File
@@ -0,0 +1,168 @@
// Package chat implements messaging primitives. sanitize.go is the
// server-side rich-text whitelist sanitizer (§2.2): allowed tags are
// b i u s a code pre ul ol li br p blockquote; only <a> keeps an attribute
// (href, http/https/mailto only). Everything else is escaped as text.
// Implemented with a small hand-written tokenizer — no parser dependency.
package chat
import (
"html"
"strings"
)
var allowedTags = map[string]bool{
"b": true, "i": true, "u": true, "s": true, "a": true, "code": true,
"pre": true, "ul": true, "ol": true, "li": true, "br": true, "p": true,
"blockquote": true,
}
var voidTags = map[string]bool{"br": true}
// SanitizeHTML rewrites untrusted rich text into safe HTML. Unknown tags are
// dropped (their text content kept), attributes are stripped (except a safe
// href on <a>), tags are re-balanced so output never leaks open elements.
func SanitizeHTML(in string) string {
var out strings.Builder
var stack []string
i := 0
for i < len(in) {
c := in[i]
if c != '<' {
j := strings.IndexByte(in[i:], '<')
if j < 0 {
out.WriteString(html.EscapeString(in[i:]))
break
}
out.WriteString(html.EscapeString(in[i : i+j]))
i += j
continue
}
// find the end of the tag
end := strings.IndexByte(in[i:], '>')
if end < 0 {
out.WriteString(html.EscapeString(in[i:]))
break
}
raw := in[i+1 : i+end] // between < and >
i += end + 1
closing := strings.HasPrefix(raw, "/")
raw = strings.TrimPrefix(raw, "/")
raw = strings.TrimSuffix(strings.TrimSpace(raw), "/")
name, attrs := splitTag(raw)
name = strings.ToLower(name)
if !allowedTags[name] {
continue // drop the tag entirely, keep surrounding text
}
if closing {
// close up to the matching open tag, if any
for n := len(stack) - 1; n >= 0; n-- {
if stack[n] == name {
for len(stack) > n {
top := stack[len(stack)-1]
stack = stack[:len(stack)-1]
out.WriteString("</" + top + ">")
}
break
}
}
continue
}
if voidTags[name] {
out.WriteString("<" + name + ">")
continue
}
if name == "a" {
href := safeHref(attrs)
if href == "" {
out.WriteString("<a>")
} else {
out.WriteString(`<a href="` + html.EscapeString(href) + `" rel="noopener noreferrer" target="_blank">`)
}
} else {
out.WriteString("<" + name + ">")
}
stack = append(stack, name)
}
// balance anything left open
for n := len(stack) - 1; n >= 0; n-- {
out.WriteString("</" + stack[n] + ">")
}
return out.String()
}
// splitTag separates the tag name from its raw attribute string.
func splitTag(raw string) (string, string) {
for i := 0; i < len(raw); i++ {
switch raw[i] {
case ' ', '\t', '\n', '\r':
return raw[:i], raw[i+1:]
}
}
return raw, ""
}
// safeHref extracts href and accepts only http, https, or mailto schemes.
func safeHref(attrs string) string {
lower := strings.ToLower(attrs)
idx := strings.Index(lower, "href")
if idx < 0 {
return ""
}
rest := strings.TrimSpace(attrs[idx+4:])
if !strings.HasPrefix(rest, "=") {
return ""
}
rest = strings.TrimSpace(rest[1:])
var val string
switch {
case strings.HasPrefix(rest, `"`):
if end := strings.IndexByte(rest[1:], '"'); end >= 0 {
val = rest[1 : 1+end]
}
case strings.HasPrefix(rest, "'"):
if end := strings.IndexByte(rest[1:], '\''); end >= 0 {
val = rest[1 : 1+end]
}
default:
val = rest
if sp := strings.IndexAny(val, " \t\n"); sp >= 0 {
val = val[:sp]
}
}
val = strings.TrimSpace(val)
low := strings.ToLower(val)
if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") ||
strings.HasPrefix(low, "mailto:") {
return val
}
return ""
}
// SanitizePlain strips ALL tags, returning escaped plain text (used where
// rich text is not allowed).
func SanitizePlain(in string) string {
var out strings.Builder
i := 0
for i < len(in) {
if in[i] != '<' {
j := strings.IndexByte(in[i:], '<')
if j < 0 {
out.WriteString(html.EscapeString(in[i:]))
break
}
out.WriteString(html.EscapeString(in[i : i+j]))
i += j
continue
}
end := strings.IndexByte(in[i:], '>')
if end < 0 {
out.WriteString(html.EscapeString(in[i:]))
break
}
i += end + 1
}
return out.String()
}
+79
View File
@@ -0,0 +1,79 @@
package chat
import (
"strings"
"testing"
)
func TestSanitizeHTML(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"plain text escaped", `hello <world> & "friends"`, "hello &amp; &#34;friends&#34;"},
{"allowed formatting kept", "<b>bold</b> <i>it</i> <u>u</u> <s>s</s>", "<b>bold</b> <i>it</i> <u>u</u> <s>s</s>"},
{"paragraph and lists", "<p>a</p><ul><li>x</li><li>y</li></ul>", "<p>a</p><ul><li>x</li><li>y</li></ul>"},
{"code and pre", "<pre><code>x := 1</code></pre>", "<pre><code>x := 1</code></pre>"},
{"blockquote", "<blockquote>q</blockquote>", "<blockquote>q</blockquote>"},
{"br void", "a<br>b<br/>c", "a<br>b<br>c"},
{"script stripped", `<script>alert(1)</script>hi`, "alert(1)hi"},
{"img dropped", `<img src=x onerror=alert(1)>safe`, "safe"},
{"event handlers stripped from allowed tag", `<b onclick="evil()">x</b>`, "<b>x</b>"},
{"style attr stripped", `<p style="position:fixed">x</p>`, "<p>x</p>"},
{"https link kept with rel", `<a href="https://x.y/z">l</a>`,
`<a href="https://x.y/z" rel="noopener noreferrer" target="_blank">l</a>`},
{"http link kept", `<a href="http://x.y">l</a>`,
`<a href="http://x.y" rel="noopener noreferrer" target="_blank">l</a>`},
{"javascript href removed", `<a href="javascript:alert(1)">l</a>`, "<a>l</a>"},
{"data href removed", `<a href="data:text/html,x">l</a>`, "<a>l</a>"},
{"single-quoted js href removed", `<a href='javascript:x'>l</a>`, "<a>l</a>"},
{"unquoted href", `<a href=https://ok.example>l</a>`,
`<a href="https://ok.example" rel="noopener noreferrer" target="_blank">l</a>`},
{"unclosed tags balanced", "<b>bold <i>both", "<b>bold <i>both</i></b>"},
{"stray close ignored", "</b>text", "text"},
{"mismatched nesting closed in order", "<b><i>x</b></i>", "<b><i>x</i></b>"},
{"case insensitive tags", "<B>x</B><SCRIPT>y</SCRIPT>", "<b>x</b>y"},
{"nested quotes attack", `<a href="https://x" "><script>alert(1)</script>">x</a>`,
`<a href="https://x" rel="noopener noreferrer" target="_blank">alert(1)&#34;&gt;x</a>`},
{"incomplete tag escaped", "a <b", "a &lt;b"},
{"empty input", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := SanitizeHTML(tt.in); got != tt.want {
t.Errorf("SanitizeHTML(%q)\n got %q\n want %q", tt.in, got, tt.want)
}
})
}
}
func TestSanitizeHTMLNeverEmitsDangerousOutput(t *testing.T) {
vectors := []string{
`<script>alert(1)</script>`,
`<img src=x onerror=alert(1)>`,
`<svg onload=alert(1)>`,
`<iframe src="https://evil"></iframe>`,
`<a href="javascript:alert(1)">x</a>`,
`<A HREF="JAVASCRIPT:alert(1)">x</A>`,
`<b onmouseover="alert(1)">x</b>`,
`<<script>script>alert(1)<</script>/script>`,
`<p><style>*{}</style></p>`,
`<form action="https://evil"><input></form>`,
}
for _, v := range vectors {
got := strings.ToLower(SanitizeHTML(v))
for _, bad := range []string{"<script", "onerror", "onload", "onmouseover",
"javascript:", "<iframe", "<svg", "<img", "<form", "<style", "<input"} {
if strings.Contains(got, bad) {
t.Errorf("vector %q produced dangerous output %q (contains %q)", v, got, bad)
}
}
}
}
func TestSanitizePlain(t *testing.T) {
if got := SanitizePlain(`<b>x</b> & <script>y</script>`); got != "x &amp; y" {
t.Errorf("SanitizePlain = %q", got)
}
}
+486
View File
@@ -0,0 +1,486 @@
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)
}
+358
View File
@@ -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)
}
+445
View File
@@ -0,0 +1,445 @@
//go:build integration
package httpx
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"bountyboard/internal/domain"
"bountyboard/internal/store"
"bountyboard/internal/workperform"
)
// lifecycleStack: admin-created customer, consultant, developer in pool, and
// one atomized task ready to publish.
type lifecycleStack struct {
ts *httptest.Server
srv *Server
st *store.Store
consultant *http.Client
consCSRF string
consUser domain.User
developer *http.Client
devCSRF string
devUser domain.User
task *domain.Task
customerID string
}
func newLifecycleStack(t *testing.T, extraEnv map[string]string) *lifecycleStack {
t.Helper()
ts, srv, st, _ := newAuthStack(t, extraEnv)
cc := newClient(t)
consUser := registerUser(t, ts.URL, cc, "lc-cons@example.com", "Lifecycle Cons")
promote(t, st, consUser.ID, map[string]bool{"consultant": true})
dc := newClient(t)
devUser := registerUser(t, ts.URL, dc, "lc-dev@example.com", "Lifecycle Dev")
customer := &domain.Customer{
Name: "LC Customer",
Ticketing: domain.Ticketing{Type: domain.TicketingDemo, ProjectKey: "LC"},
ConsultantIDs: []string{consUser.ID},
DefaultBudget: 1000,
}
if err := st.CreateCustomer(t.Context(), customer); err != nil {
t.Fatal(err)
}
// consultant pools the developer
consCSRF := csrfFrom(t, cc, ts.URL)
resp := postJSON(t, cc, ts.URL+"/api/v1/consultant/pool",
map[string]string{"developerId": devUser.ID}, consCSRF)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("add to pool: %d", resp.StatusCode)
}
resp.Body.Close()
task := &domain.Task{
CustomerID: customer.ID, ConsultantID: consUser.ID,
Origin: domain.OriginSubdivided, Status: domain.StatusAtomized,
Title: "Build the CSV importer", Description: "as specified",
AcceptanceCriteria: []string{"imports valid rows", "reports bad rows"},
EffortCoefficient: 0.25, Budget: 1000, Bounty: 250,
}
if err := st.InsertTask(t.Context(), task); err != nil {
t.Fatal(err)
}
return &lifecycleStack{
ts: ts, srv: srv, st: st,
consultant: cc, consCSRF: consCSRF, consUser: consUser,
developer: dc, devCSRF: csrfFrom(t, dc, ts.URL), devUser: devUser,
task: task, customerID: customer.ID,
}
}
func (l *lifecycleStack) taskStatus(t *testing.T) domain.TaskStatus {
t.Helper()
fresh, err := l.st.TaskByID(t.Context(), l.task.ID)
if err != nil {
t.Fatal(err)
}
return fresh.Status
}
func mustPost(t *testing.T, c *http.Client, url string, body any, csrf string, want int) *http.Response {
t.Helper()
resp := postJSON(t, c, url, body, csrf)
if resp.StatusCode != want {
var buf bytes.Buffer
buf.ReadFrom(resp.Body)
t.Fatalf("POST %s: %d (want %d): %s", url, resp.StatusCode, want, buf.String())
}
return resp
}
func TestFullLifecycleImportedToApproved(t *testing.T) {
l := newLifecycleStack(t, nil)
base := l.ts.URL + "/api/v1/tasks/" + l.task.ID
// consultant publishes
mustPost(t, l.consultant, base+"/publish", map[string]any{}, l.consCSRF, http.StatusOK).Body.Close()
if got := l.taskStatus(t); got != domain.StatusPublished {
t.Fatalf("after publish: %s", got)
}
// developer sees it on the board with the right bounty
resp, err := l.developer.Get(l.ts.URL + "/api/v1/board")
if err != nil {
t.Fatal(err)
}
var board struct {
Tasks []domain.Task `json:"tasks"`
}
bodyJSON(t, resp, &board)
if len(board.Tasks) != 1 || board.Tasks[0].Bounty != 250 {
t.Fatalf("board: %+v", board.Tasks)
}
// developer claims
mustPost(t, l.developer, base+"/claim", map[string]string{"note": "I know CSV"}, l.devCSRF, http.StatusNoContent).Body.Close()
if got := l.taskStatus(t); got != domain.StatusClaimRequested {
t.Fatalf("after claim: %s", got)
}
// premature lifecycle calls are rejected
resp = postJSON(t, l.developer, base+"/start", map[string]any{}, l.devCSRF)
if resp.StatusCode != http.StatusForbidden { // not assignee yet
t.Fatalf("start before assignment: %d", resp.StatusCode)
}
resp.Body.Close()
// consultant approves the claim
mustPost(t, l.consultant, base+"/approve-claim",
map[string]string{"developerId": l.devUser.ID}, l.consCSRF, http.StatusNoContent).Body.Close()
if got := l.taskStatus(t); got != domain.StatusAssigned {
t.Fatalf("after approve-claim: %s", got)
}
// developer starts, logs time, comments, submits
mustPost(t, l.developer, base+"/start", map[string]any{}, l.devCSRF, http.StatusNoContent).Body.Close()
mustPost(t, l.developer, base+"/time",
map[string]any{"minutes": 90, "note": "parser"}, l.devCSRF, http.StatusCreated).Body.Close()
mustPost(t, l.developer, base+"/comments",
map[string]any{"body": "<p>Done, see <b>PR</b> <script>alert(1)</script></p>"},
l.devCSRF, http.StatusCreated).Body.Close()
mustPost(t, l.developer, base+"/submit-review", map[string]any{}, l.devCSRF, http.StatusNoContent).Body.Close()
if got := l.taskStatus(t); got != domain.StatusInReview {
t.Fatalf("after submit: %s", got)
}
// comment was sanitized
fresh, _ := l.st.TaskByID(t.Context(), l.task.ID)
if len(fresh.Comments) != 1 || bytes.Contains([]byte(fresh.Comments[0].Body), []byte("<script")) {
t.Fatalf("comment sanitization: %+v", fresh.Comments)
}
if len(fresh.TimeLog) != 1 || fresh.TimeLog[0].Minutes != 90 {
t.Fatalf("time log: %+v", fresh.TimeLog)
}
// consultant reviews in the queue, then requests changes
qResp, err := l.consultant.Get(l.ts.URL + "/api/v1/consultant/reviews")
if err != nil {
t.Fatal(err)
}
var queue struct {
Tasks []domain.Task `json:"tasks"`
}
bodyJSON(t, qResp, &queue)
if len(queue.Tasks) != 1 {
t.Fatalf("review queue: %d tasks", len(queue.Tasks))
}
mustPost(t, l.consultant, base+"/review",
map[string]any{"decision": "request_changes", "note": "handle BOM"},
l.consCSRF, http.StatusNoContent).Body.Close()
if got := l.taskStatus(t); got != domain.StatusChangesRequested {
t.Fatalf("after request_changes: %s", got)
}
// developer resumes and resubmits; consultant approves with checklist
mustPost(t, l.developer, base+"/start", map[string]any{}, l.devCSRF, http.StatusNoContent).Body.Close()
mustPost(t, l.developer, base+"/submit-review", map[string]any{}, l.devCSRF, http.StatusNoContent).Body.Close()
mustPost(t, l.consultant, base+"/review", map[string]any{
"decision": "approve", "note": "nice",
"checklist": []map[string]any{
{"criterion": "imports valid rows", "ok": true},
{"criterion": "reports bad rows", "ok": true},
},
}, l.consCSRF, http.StatusNoContent).Body.Close()
if got := l.taskStatus(t); got != domain.StatusApproved {
t.Fatalf("after approve: %s", got)
}
// immutable bountyAwards row exists with the frozen amount
var award store.BountyAward
if err := l.st.DB.Collection("bountyAwards").FindOne(t.Context(),
bson.M{"taskId": l.task.ID}).Decode(&award); err != nil {
t.Fatalf("award row: %v", err)
}
if award.DeveloperID != l.devUser.ID || award.Amount != 250 || award.Coefficient != 0.25 {
t.Fatalf("award: %+v", award)
}
// the review checklist landed on the timeline (§11.18)
fresh, _ = l.st.TaskByID(t.Context(), l.task.ID)
var checklistOK bool
for _, e := range fresh.Timeline {
if e.Event == "approved" && e.Data["checklist"] != nil {
checklistOK = true
}
}
if !checklistOK {
t.Error("timeline missing approval checklist")
}
// developer got the approval notification
notifs, _ := l.st.ListNotifications(t.Context(), l.devUser.ID, false, "", 50)
var approvedNotif bool
for _, n := range notifs {
if n.Kind == "approved" {
approvedNotif = true
}
}
if !approvedNotif {
t.Error("developer missing approval notification")
}
// approved task is immutable
resp = patchJSON(t, l.consultant, base, map[string]any{"budget": 9999.0}, l.consCSRF)
if resp.StatusCode != http.StatusConflict {
t.Fatalf("edit after approve: %d, want 409", resp.StatusCode)
}
resp.Body.Close()
}
func TestDeclineWithdrawUnassignAbandon(t *testing.T) {
l := newLifecycleStack(t, nil)
base := l.ts.URL + "/api/v1/tasks/" + l.task.ID
mustPost(t, l.consultant, base+"/publish", map[string]any{}, l.consCSRF, http.StatusOK).Body.Close()
// claim + consultant declines → back to published, dev notified
mustPost(t, l.developer, base+"/claim", map[string]string{"note": ""}, l.devCSRF, http.StatusNoContent).Body.Close()
mustPost(t, l.consultant, base+"/decline-claim",
map[string]string{"developerId": l.devUser.ID}, l.consCSRF, http.StatusNoContent).Body.Close()
if got := l.taskStatus(t); got != domain.StatusPublished {
t.Fatalf("after decline: %s", got)
}
notifs, _ := l.st.ListNotifications(t.Context(), l.devUser.ID, false, "", 50)
var declined bool
for _, n := range notifs {
if n.Kind == "claim_declined" {
declined = true
}
}
if !declined {
t.Error("declined developer was not notified")
}
// claim + withdraw → back to published
mustPost(t, l.developer, base+"/claim", map[string]string{}, l.devCSRF, http.StatusNoContent).Body.Close()
mustPost(t, l.developer, base+"/claim/withdraw", map[string]any{}, l.devCSRF, http.StatusNoContent).Body.Close()
if got := l.taskStatus(t); got != domain.StatusPublished {
t.Fatalf("after withdraw: %s", got)
}
// assign → unassign by consultant → published again
mustPost(t, l.developer, base+"/claim", map[string]string{}, l.devCSRF, http.StatusNoContent).Body.Close()
mustPost(t, l.consultant, base+"/approve-claim",
map[string]string{"developerId": l.devUser.ID}, l.consCSRF, http.StatusNoContent).Body.Close()
mustPost(t, l.consultant, base+"/unassign", map[string]any{}, l.consCSRF, http.StatusNoContent).Body.Close()
if got := l.taskStatus(t); got != domain.StatusPublished {
t.Fatalf("after unassign: %s", got)
}
fresh, _ := l.st.TaskByID(t.Context(), l.task.ID)
if fresh.Assignee != nil {
t.Fatalf("assignee not cleared: %+v", fresh.Assignee)
}
// assign again → developer abandons mid-progress → published
mustPost(t, l.developer, base+"/claim", map[string]string{}, l.devCSRF, http.StatusNoContent).Body.Close()
mustPost(t, l.consultant, base+"/approve-claim",
map[string]string{"developerId": l.devUser.ID}, l.consCSRF, http.StatusNoContent).Body.Close()
mustPost(t, l.developer, base+"/start", map[string]any{}, l.devCSRF, http.StatusNoContent).Body.Close()
mustPost(t, l.developer, base+"/abandon", map[string]any{}, l.devCSRF, http.StatusNoContent).Body.Close()
if got := l.taskStatus(t); got != domain.StatusPublished {
t.Fatalf("after abandon: %s", got)
}
}
func TestDeveloperOutsidePoolSeesNothing(t *testing.T) {
l := newLifecycleStack(t, nil)
base := l.ts.URL + "/api/v1/tasks/" + l.task.ID
mustPost(t, l.consultant, base+"/publish", map[string]any{}, l.consCSRF, http.StatusOK).Body.Close()
stranger := newClient(t)
registerUser(t, l.ts.URL, stranger, "stranger@example.com", "Stranger")
strangerCSRF := csrfFrom(t, stranger, l.ts.URL)
resp, err := stranger.Get(l.ts.URL + "/api/v1/board")
if err != nil {
t.Fatal(err)
}
var board struct {
Tasks []domain.Task `json:"tasks"`
}
bodyJSON(t, resp, &board)
if len(board.Tasks) != 0 {
t.Fatalf("stranger sees %d tasks", len(board.Tasks))
}
resp = postJSON(t, stranger, base+"/claim", map[string]string{}, strangerCSRF)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("stranger claim: %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestAIAssignmentAndCallback(t *testing.T) {
const wpToken = "test-wp-token-abc"
// fake work performer + artifact host
artifact := []byte("diff --git a/x b/x\n+fixed\n")
var fake *httptest.Server
fake = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "POST" && r.URL.Path == "/v1/jobs":
if r.Header.Get("Authorization") != "Bearer "+wpToken {
http.Error(w, "bad token", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
json.NewEncoder(w).Encode(map[string]string{"jobId": "wp_test_1", "status": "queued"})
case r.URL.Path == "/healthz":
w.WriteHeader(http.StatusOK)
case r.URL.Path == "/artifacts/patch.diff":
w.Write(artifact)
default:
http.NotFound(w, r)
}
}))
defer fake.Close()
l := newLifecycleStack(t, map[string]string{"WORK_PERFORMER_TOKEN": wpToken})
l.srv.SetPerformerClient(workperform.New(func() string { return fake.URL }, wpToken, 5*time.Second))
base := l.ts.URL + "/api/v1/tasks/" + l.task.ID
mustPost(t, l.consultant, base+"/publish", map[string]any{}, l.consCSRF, http.StatusOK).Body.Close()
// consultant assigns to AI
resp := mustPost(t, l.consultant, base+"/assign-ai",
map[string]any{"context": map[string]string{"instructions": "be careful"}},
l.consCSRF, http.StatusAccepted)
var accepted struct {
JobID string `json:"jobId"`
}
bodyJSON(t, resp, &accepted)
if accepted.JobID != "wp_test_1" {
t.Fatalf("jobId = %q", accepted.JobID)
}
if got := l.taskStatus(t); got != domain.StatusAssigned {
t.Fatalf("after assign-ai: %s", got)
}
// the service calls back with an HMAC-signed result
cb := workperform.Callback{
JobID: "wp_test_1", TaskID: l.task.ID, Status: "succeeded",
Summary: "Implemented the importer", Log: "ran fine",
}
cb.Artifacts = []struct {
Name string `json:"name"`
URL string `json:"url"`
}{{Name: "patch.diff", URL: fake.URL + "/artifacts/patch.diff"}}
body, _ := json.Marshal(cb)
post := func(sig string) *http.Response {
req, _ := http.NewRequest(http.MethodPost,
l.ts.URL+"/api/v1/internal/work-results", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Signature", sig)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
// wrong signature rejected
r1 := post("deadbeef")
if r1.StatusCode != http.StatusUnauthorized {
t.Fatalf("bad signature: %d", r1.StatusCode)
}
r1.Body.Close()
// valid signature moves the task to in_review with ingested artifact
r2 := post(workperform.Sign(wpToken, body))
if r2.StatusCode != http.StatusOK {
t.Fatalf("callback: %d", r2.StatusCode)
}
r2.Body.Close()
if got := l.taskStatus(t); got != domain.StatusInReview {
t.Fatalf("after callback: %s", got)
}
fresh, _ := l.st.TaskByID(t.Context(), l.task.ID)
var stored *domain.TaskAttachment
for i := range fresh.Attachments {
if fresh.Attachments[i].Name == "patch.diff" {
stored = &fresh.Attachments[i]
}
}
if stored == nil || stored.FileID == "" {
t.Fatalf("artifact not ingested: %+v", fresh.Attachments)
}
// duplicate callback is idempotent (no error, no double transition)
r3 := post(workperform.Sign(wpToken, body))
if r3.StatusCode != http.StatusOK {
t.Fatalf("duplicate callback: %d", r3.StatusCode)
}
var dup map[string]string
bodyJSON(t, r3, &dup)
if dup["status"] != "duplicate_ignored" {
t.Fatalf("duplicate callback status: %v", dup)
}
// consultant reviews the AI work exactly like a human's
mustPost(t, l.consultant, base+"/review",
map[string]any{"decision": "approve", "note": "AI did fine"},
l.consCSRF, http.StatusNoContent).Body.Close()
if got := l.taskStatus(t); got != domain.StatusApproved {
t.Fatalf("after AI approve: %s", got)
}
// no bounty award for AI assignees
n, _ := l.st.DB.Collection("bountyAwards").CountDocuments(t.Context(), bson.M{"taskId": l.task.ID})
if n != 0 {
t.Fatalf("AI task produced %d award rows, want 0", n)
}
}
+19 -8
View File
@@ -14,6 +14,7 @@ import (
"bountyboard/internal/files" "bountyboard/internal/files"
"bountyboard/internal/metrics" "bountyboard/internal/metrics"
"bountyboard/internal/store" "bountyboard/internal/store"
"bountyboard/internal/workperform"
) )
// shutdownBudget is the §12 graceful-shutdown drain window. // shutdownBudget is the §12 graceful-shutdown drain window.
@@ -33,14 +34,21 @@ type Server struct {
httpSrv *http.Server httpSrv *http.Server
// late-bound subsystem hooks (see providers.go) // late-bound subsystem hooks (see providers.go)
atomizer BreakerInfo atomizer BreakerInfo
performer BreakerInfo performer BreakerInfo
syncProvider StatusProvider syncProvider StatusProvider
jobsProvider StatusProvider jobsProvider StatusProvider
syncTrigger func(customerID string) syncTrigger func(customerID string)
publishFn func(channel, event string, payload any) publishFn func(channel, event string, payload any)
enqueueFn func(ctx context.Context, kind string, payload any) (string, error) enqueueFn func(ctx context.Context, kind string, payload any) (string, error)
wsHandler func(w http.ResponseWriter, r *http.Request, userID string) wsHandler func(w http.ResponseWriter, r *http.Request, userID string)
performerClient *workperform.Client
}
// SetPerformerClient wires the §5.2 client (also feeds the status panel).
func (s *Server) SetPerformerClient(c *workperform.Client) {
s.performerClient = c
s.performer = c
} }
func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.Store, fs *files.Store) *Server { func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.Store, fs *files.Store) *Server {
@@ -70,6 +78,9 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.
s.routesProfile(mux) s.routesProfile(mux)
s.routesAdmin(mux) s.routesAdmin(mux)
s.routesTasks(mux) s.routesTasks(mux)
s.routesBoard(mux)
s.routesConsultant(mux)
s.routesWorkResults(mux)
mux.HandleFunc("GET /ws", s.handleWS) mux.HandleFunc("GET /ws", s.handleWS)
s.routesWeb(mux) s.routesWeb(mux)
+29 -6
View File
@@ -192,14 +192,37 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
}) })
})) }))
rolePage := func(path, tmpl, title, active, script string, check func(*domain.User) bool) {
mux.HandleFunc("GET "+path, s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
if check != nil && !check(u) && !u.Roles.Admin {
s.render(w, r, "placeholder.html", &pageData{Title: "Not authorized", User: u})
return
}
s.render(w, r, tmpl, &pageData{
Title: title, Active: active, User: u, Scripts: []string{script},
})
}))
}
isDev := func(u *domain.User) bool { return u.Roles.Developer }
isCons := func(u *domain.User) bool { return u.Roles.Consultant }
rolePage("/board", "board.html", "Bounty Board", "board", "/static/js/board.js", isDev)
rolePage("/my-tasks", "my-tasks.html", "My Tasks", "my-tasks", "/static/js/my-tasks.js", isDev)
rolePage("/consultant/reviews", "reviews.html", "Review Queue", "reviews", "/static/js/reviews.js", isCons)
rolePage("/consultant/pool", "pool.html", "Developer Pool", "pool", "/static/js/pool.js", isCons)
rolePage("/notifications", "notifications.html", "Notifications", "", "/static/js/notifications-page.js", nil)
mux.HandleFunc("GET /tasks/{id}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
s.render(w, r, "task.html", &pageData{
Title: "Task", User: u,
Scripts: []string{"/static/js/task.js"},
Data: map[string]any{"TaskID": r.PathValue("id")},
})
}))
// Placeholders for areas built in later phases; replaced as they land. // Placeholders for areas built in later phases; replaced as they land.
placeholders := map[string]string{ placeholders := map[string]string{
"/board": "Bounty Board", "/messages": "Messages",
"/my-tasks": "My Tasks", "/metrics": "Metrics",
"/consultant/reviews": "Review Queue",
"/consultant/pool": "Developer Pool",
"/messages": "Messages",
"/metrics": "Metrics",
} }
for path, title := range placeholders { for path, title := range placeholders {
mux.HandleFunc("GET "+path, s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) { mux.HandleFunc("GET "+path, s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
+141
View File
@@ -0,0 +1,141 @@
package httpx
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"bountyboard/internal/domain"
"bountyboard/internal/files"
"bountyboard/internal/workperform"
)
func (s *Server) routesWorkResults(mux *http.ServeMux) {
// Service-to-app callback: authenticated by HMAC signature, not session.
mux.HandleFunc("POST /api/v1/internal/work-results", s.handleWorkResults)
}
// handleWorkResults implements the §5.2 callback: signature-verified,
// idempotent by jobId, artifacts ingested into GridFS so review survives the
// performer container.
func (s *Server) handleWorkResults(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 4<<20))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "unreadable body")
return
}
if !workperform.VerifySignature(s.cfg.WorkPerformerToken, body, r.Header.Get("X-Signature")) {
s.metrics.Inc("wp_callback_bad_signature_total", 1)
writeError(w, http.StatusUnauthorized, "bad_signature", "invalid X-Signature")
return
}
var cb workperform.Callback
if err := json.Unmarshal(body, &cb); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "invalid JSON: "+err.Error())
return
}
if cb.JobID == "" || cb.TaskID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "jobId and taskId are required")
return
}
// Idempotency by jobId: first writer wins, duplicates are acknowledged
// without effect (§5.2).
_, err = s.store.DB.Collection("wpCallbacks").InsertOne(r.Context(), bson.M{
"_id": cb.JobID, "taskId": cb.TaskID, "status": cb.Status, "at": time.Now().UTC(),
})
if err != nil {
if mongo.IsDuplicateKeyError(err) {
writeJSON(w, http.StatusOK, map[string]string{"status": "duplicate_ignored"})
return
}
s.internalError(w, r, "record callback", err)
return
}
t, err := s.store.TaskByID(r.Context(), cb.TaskID)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "task not found")
return
}
if t.Assignee == nil || t.Assignee.Kind != "ai" || t.Assignee.JobID != cb.JobID {
writeError(w, http.StatusConflict, "job_mismatch", "task is not assigned to this job")
return
}
switch cb.Status {
case "succeeded":
attachments := s.ingestArtifacts(r.Context(), t, cb)
err = s.store.TransitionTask(r.Context(), t, domain.StatusInReview, "system:ai", "ai_completed",
map[string]any{"jobId": cb.JobID, "summary": cb.Summary, "log": tail(cb.Log, 4000)},
map[string]any{"attachments": attachments})
if err != nil {
s.taskTransitionError(w, err)
return
}
s.notifyUser(r.Context(), t.ConsultantID, "review_requested",
"AI finished: "+t.Title, cb.Summary, "/tasks/"+t.ID)
case "failed":
// status stays assigned (§5.2); record + notify
if err := s.store.AppendTimeline(r.Context(), t.ID, domain.TimelineEntry{
ActorID: "system:ai", Event: "ai_failed",
Data: map[string]any{"jobId": cb.JobID, "summary": cb.Summary, "log": tail(cb.Log, 4000)},
}); err != nil {
s.internalError(w, r, "record ai failure", err)
return
}
s.notifyUser(r.Context(), t.ConsultantID, "ai_failed",
"AI work failed: "+t.Title, cb.Summary, "/tasks/"+t.ID)
default:
writeError(w, http.StatusBadRequest, "bad_status", "status must be succeeded or failed")
return
}
s.Publish("board", "task.updated", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
writeJSON(w, http.StatusOK, map[string]string{"status": "accepted"})
}
// ingestArtifacts downloads every artifact URL into GridFS; failures are
// recorded but never block the review (the summary alone is reviewable).
func (s *Server) ingestArtifacts(ctx context.Context, t *domain.Task, cb workperform.Callback) []domain.TaskAttachment {
attachments := append([]domain.TaskAttachment{}, t.Attachments...)
client := &http.Client{Timeout: 60 * time.Second}
for _, a := range cb.Artifacts {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.URL, nil)
if err != nil {
continue
}
resp, err := client.Do(req)
if err != nil {
s.log.Warn("download artifact", "url", a.URL, "err", err)
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
s.log.Warn("download artifact", "url", a.URL, "status", resp.StatusCode)
continue
}
meta, err := s.files.Save(ctx, files.ScopeTask, "system:ai", a.Name, "", resp.Body)
resp.Body.Close()
if err != nil {
s.log.Warn("store artifact", "name", a.Name, "err", err)
continue
}
attachments = append(attachments, domain.TaskAttachment{
Name: a.Name, URL: "/files/" + meta.ID, MimeType: meta.MimeType, FileID: meta.ID,
})
}
return attachments
}
func tail(s string, n int) string {
if len(s) <= n {
return s
}
return fmt.Sprintf("…%s", s[len(s)-n:])
}
+31
View File
@@ -0,0 +1,31 @@
package store
import (
"context"
"fmt"
"time"
"bountyboard/internal/ulid"
)
// BountyAward is the immutable ledger row (§4.5) — never updated, never
// recomputed from mutable tasks.
type BountyAward struct {
ID string `bson:"_id" json:"id"`
TaskID string `bson:"taskId" json:"taskId"`
DeveloperID string `bson:"developerId" json:"developerId"`
ConsultantID string `bson:"consultantId" json:"consultantId"`
CustomerID string `bson:"customerId" json:"customerId"`
Amount float64 `bson:"amount" json:"amount"`
Coefficient float64 `bson:"coefficient" json:"coefficient"`
AwardedAt time.Time `bson:"awardedAt" json:"awardedAt"`
}
func (s *Store) InsertAward(ctx context.Context, a *BountyAward) error {
a.ID = ulid.New()
a.AwardedAt = time.Now().UTC()
if _, err := s.DB.Collection("bountyAwards").InsertOne(ctx, a); err != nil {
return fmt.Errorf("insert award: %w", err)
}
return nil
}
+77
View File
@@ -0,0 +1,77 @@
package store
import (
"context"
"fmt"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"bountyboard/internal/ulid"
)
// PoolEntry is a developer-pool membership row (§4.3).
type PoolEntry struct {
ID string `bson:"_id" json:"id"`
ConsultantID string `bson:"consultantId" json:"consultantId"`
DeveloperID string `bson:"developerId" json:"developerId"`
AddedAt time.Time `bson:"addedAt" json:"addedAt"`
}
func (s *Store) AddToPool(ctx context.Context, consultantID, developerID string) error {
_, err := s.DB.Collection("pools").InsertOne(ctx, PoolEntry{
ID: ulid.New(), ConsultantID: consultantID, DeveloperID: developerID,
AddedAt: time.Now().UTC(),
})
if err != nil {
if mongo.IsDuplicateKeyError(err) {
return ErrDuplicate
}
return fmt.Errorf("add to pool: %w", err)
}
return nil
}
func (s *Store) RemoveFromPool(ctx context.Context, consultantID, developerID string) error {
res, err := s.DB.Collection("pools").DeleteOne(ctx,
bson.M{"consultantId": consultantID, "developerId": developerID})
if err != nil {
return fmt.Errorf("remove from pool: %w", err)
}
if res.DeletedCount == 0 {
return ErrNotFound
}
return nil
}
// PoolDeveloperIDs lists developers in one consultant's pool.
func (s *Store) PoolDeveloperIDs(ctx context.Context, consultantID string) ([]string, error) {
return s.poolSide(ctx, bson.M{"consultantId": consultantID}, "developerId")
}
// PoolConsultantIDs lists consultants who selected the developer — drives
// the developer's bounty-board visibility (§3).
func (s *Store) PoolConsultantIDs(ctx context.Context, developerID string) ([]string, error) {
return s.poolSide(ctx, bson.M{"developerId": developerID}, "consultantId")
}
func (s *Store) poolSide(ctx context.Context, filter bson.M, field string) ([]string, error) {
cur, err := s.DB.Collection("pools").Find(ctx, filter)
if err != nil {
return nil, fmt.Errorf("list pool: %w", err)
}
var rows []PoolEntry
if err := cur.All(ctx, &rows); err != nil {
return nil, fmt.Errorf("decode pool: %w", err)
}
out := make([]string, 0, len(rows))
for _, r := range rows {
if field == "developerId" {
out = append(out, r.DeveloperID)
} else {
out = append(out, r.ConsultantID)
}
}
return out, nil
}
+112
View File
@@ -0,0 +1,112 @@
// Package workperform is the app-side client of the Work Performer Service
// (§5.2) — fully independent from the atomizer: separate base URL, token,
// and circuit breaker.
package workperform
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"time"
"bountyboard/internal/extsvc"
)
type Attachment struct {
Name string `json:"name"`
URL string `json:"url"`
MimeType string `json:"mimeType"`
}
type JobContext struct {
RepositoryURL string `json:"repositoryUrl,omitempty"`
Branch string `json:"branch,omitempty"`
Instructions string `json:"instructions,omitempty"`
}
type JobRequest struct {
TaskID string `json:"taskId"`
Title string `json:"title"`
Description string `json:"description"`
AcceptanceCriteria []string `json:"acceptanceCriteria"`
Attachments []Attachment `json:"attachments"`
Links []string `json:"links"`
Context JobContext `json:"context"`
CallbackURL string `json:"callbackUrl"`
}
type JobAccepted struct {
JobID string `json:"jobId"`
Status string `json:"status"`
}
type JobStatus struct {
JobID string `json:"jobId"`
Status string `json:"status"` // queued | running | succeeded | failed
StartedAt string `json:"startedAt"`
FinishedAt string `json:"finishedAt"`
}
// Callback is the §5.2 service→app result payload.
type Callback struct {
JobID string `json:"jobId"`
TaskID string `json:"taskId"`
Status string `json:"status"` // succeeded | failed
Summary string `json:"summary"`
Artifacts []struct {
Name string `json:"name"`
URL string `json:"url"`
} `json:"artifacts"`
Log string `json:"log"`
}
type Client struct {
c *extsvc.Client
token string
}
func New(baseURL func() string, token string, timeout time.Duration) *Client {
return &Client{c: extsvc.NewClient("work-performer", baseURL, token, timeout), token: token}
}
func (w *Client) BreakerState() string { return w.c.BreakerState() }
func (w *Client) Healthy(ctx context.Context) error { return w.c.Healthy(ctx) }
func (w *Client) Submit(ctx context.Context, req JobRequest) (*JobAccepted, error) {
var out JobAccepted
if err := w.c.Call(ctx, http.MethodPost, "/v1/jobs", req, &out); err != nil {
return nil, err
}
return &out, nil
}
func (w *Client) Status(ctx context.Context, jobID string) (*JobStatus, error) {
var out JobStatus
if err := w.c.Call(ctx, http.MethodGet, "/v1/jobs/"+jobID, nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// Cancel is best effort (§5.2).
func (w *Client) Cancel(ctx context.Context, jobID string) error {
return w.c.Call(ctx, http.MethodDelete, "/v1/jobs/"+jobID, nil, nil)
}
// VerifySignature checks the §5.2 callback HMAC:
// X-Signature = hex(hmac-sha256(body, WORK_PERFORMER_TOKEN)).
func VerifySignature(token string, body []byte, signature string) bool {
mac := hmac.New(sha256.New, []byte(token))
mac.Write(body)
want := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(want), []byte(signature))
}
// Sign produces the callback signature (used by tests and the mock service).
func Sign(token string, body []byte) string {
mac := hmac.New(sha256.New, []byte(token))
mac.Write(body)
return hex.EncodeToString(mac.Sum(nil))
}
+27
View File
@@ -155,6 +155,33 @@ table.list th { color: var(--muted); font-size: 0.875rem; }
.toast.err { border-left-color: var(--err); } .toast.err { border-left-color: var(--err); }
.toast.ok { border-left-color: var(--ok); } .toast.ok { border-left-color: var(--ok); }
/* kanban */
.kanban { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.kanban-col {
background: var(--surface2); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px; min-height: 200px;
}
.kanban-col h2 { font-size: 0.9rem; text-transform: uppercase; color: var(--muted); }
/* notification bell */
.bell-badge {
position: absolute; top: -4px; right: -4px;
background: var(--err); color: #fff;
font-size: 0.65rem; font-weight: 700;
padding: 1px 5px; border-radius: var(--radius);
}
.notif-panel {
position: absolute; right: 0; top: 36px; z-index: 50;
width: 320px; max-height: 420px; overflow: auto;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 12px;
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
}
.notif-list { list-style: none; margin: 0 0 8px; padding: 0; }
.notif-list li { padding: 8px 0; border-bottom: 1px solid var(--border); }
.notif-list a { color: var(--text); text-decoration: none; }
.notif-list a:hover { text-decoration: none; opacity: 0.85; }
/* atomizing progress shimmer */ /* atomizing progress shimmer */
.shimmer { .shimmer {
position: relative; position: relative;
+144
View File
@@ -0,0 +1,144 @@
import { api, toast } from '/static/js/api.js';
import { subscribe, onPollFallback } from '/static/js/ws.js';
const grid = document.getElementById('board-grid');
const errorBox = document.getElementById('error');
let meId = '';
let tasks = [];
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
function filters() {
return {
q: document.getElementById('f-q').value.trim(),
customerId: document.getElementById('f-customer').value,
minBounty: document.getElementById('f-min').value || '0',
sort: document.getElementById('f-sort').value,
};
}
async function load() {
try {
const f = filters();
const qs = new URLSearchParams(f).toString();
const res = await api('GET', '/api/v1/board?' + qs);
tasks = res.tasks;
const sel = document.getElementById('f-customer');
if (sel.options.length === 1) {
res.customers.forEach((c) => sel.add(new Option(c.name, c.id)));
}
render();
} catch (e) { errorBox.textContent = e.message; }
}
function ageBadge(t) {
if (!t.publishedAt) return '';
const days = (Date.now() - new Date(t.publishedAt).getTime()) / 86400000;
if (days > 14) return '<span class="badge" style="color:var(--err)" title="Published more than 14 days ago">14d+</span>';
if (days > 7) return '<span class="badge" style="color:var(--warn)" title="Published more than 7 days ago">7d+</span>';
return '';
}
function render() {
grid.replaceChildren(...tasks.map(renderCard));
if (!tasks.length) {
grid.innerHTML = '<p class="muted">No published tasks right now. Consultants add you to their pool to share work here.</p>';
}
}
function renderCard(t) {
const card = document.createElement('div');
card.className = 'card';
const myClaim = (t.claimRequests || []).some((c) => c.developerId === meId);
const others = (t.claimRequests || []).filter((c) => c.developerId !== meId).length;
card.innerHTML = `
<div class="spread">
<span class="badge accent" style="font-size:1rem">◈ ${t.bounty}</span>
<span>${ageBadge(t)}</span>
</div>
<h3 class="mt"><a href="/tasks/${t.id}">${esc(t.title)}</a></h3>
<p class="muted">${esc((t.description || '').slice(0, 180))}</p>
<p class="muted">${(t.acceptanceCriteria || []).length} acceptance criteria</p>
<div class="spread">
<span class="muted">
${myClaim ? '<span class="badge" style="color:var(--ok)">requested by you</span>' : ''}
${others ? `<span class="badge">${others} other request(s)</span>` : ''}
</span>
${myClaim
? '<button class="btn small" data-act="withdraw">Withdraw</button>'
: '<button class="btn primary small" data-act="claim">Request assignment</button>'}
</div>`;
const claimBtn = card.querySelector('[data-act=claim]');
if (claimBtn) claimBtn.addEventListener('click', () => openClaim(t));
const wBtn = card.querySelector('[data-act=withdraw]');
if (wBtn) {
wBtn.addEventListener('click', async () => {
try { await api('POST', `/api/v1/tasks/${t.id}/claim/withdraw`, {}); load(); }
catch (e) { toast(e.message, 'err'); }
});
}
return card;
}
const dlg = document.getElementById('claim-dialog');
let claimTask = null;
function openClaim(t) {
claimTask = t;
document.getElementById('claim-task-title').textContent = `${t.title} — bounty ${t.bounty}`;
dlg.showModal();
}
document.getElementById('claim-cancel').addEventListener('click', () => dlg.close());
document.getElementById('claim-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api('POST', `/api/v1/tasks/${claimTask.id}/claim`, {
note: document.getElementById('claim-note').value.trim(),
});
dlg.close();
document.getElementById('claim-note').value = '';
toast('Assignment requested — the consultant will review it.', 'ok');
load();
} catch (err) { toast(err.message, 'err'); }
});
// saved filters (§11.8)
document.getElementById('f-save').addEventListener('click', async () => {
try {
const me = await api('GET', '/api/v1/auth/me');
const extra = me.user.extra || {};
extra.savedBoardFilters = JSON.stringify(filters());
await api('PATCH', '/api/v1/profile', { extra });
toast('Filters saved as your default.', 'ok');
} catch (e) { toast(e.message, 'err'); }
});
async function restoreFilters() {
try {
const me = await api('GET', '/api/v1/auth/me');
meId = me.user.id;
const saved = me.user.extra && me.user.extra.savedBoardFilters;
if (saved) {
const f = JSON.parse(saved);
document.getElementById('f-q').value = f.q || '';
document.getElementById('f-min').value = f.minBounty > 0 ? f.minBounty : '';
document.getElementById('f-sort').value = f.sort || '';
}
} catch (e) { /* anonymous — page guard handles it */ }
}
let timer = null;
['f-q', 'f-min'].forEach((id) => {
document.getElementById(id).addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(load, 300);
});
});
['f-customer', 'f-sort'].forEach((id) => {
document.getElementById(id).addEventListener('change', load);
});
subscribe('board', () => { clearTimeout(timer); timer = setTimeout(load, 400); });
onPollFallback(load);
restoreFilters().then(load);
+54
View File
@@ -0,0 +1,54 @@
import { api, toast } from '/static/js/api.js';
import { subscribe, onPollFallback } from '/static/js/ws.js';
const errorBox = document.getElementById('error');
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
// changes_requested renders in the In-progress column with a badge
const colFor = (s) => (s === 'changes_requested' ? 'in_progress' : s);
async function load() {
try {
const res = await api('GET', '/api/v1/my-tasks');
document.querySelectorAll('.kanban-col .stack').forEach((el) => el.replaceChildren());
res.tasks.forEach((t) => {
const col = document.querySelector(`[data-col="${colFor(t.status)}"] .stack`);
if (col) col.appendChild(card(t));
});
} catch (e) { errorBox.textContent = e.message; }
}
function card(t) {
const el = document.createElement('div');
el.className = 'card';
el.style.padding = '12px';
const badges = [];
if (t.status === 'changes_requested') badges.push('<span class="badge" style="color:var(--warn)">changes requested</span>');
el.innerHTML = `
<a href="/tasks/${t.id}"><strong>${esc(t.title)}</strong></a> ${badges.join(' ')}
<div class="spread mt">
<span class="badge accent">◈ ${t.bounty}</span>
<span data-actions></span>
</div>`;
const actions = el.querySelector('[data-actions]');
const act = (label, path, primary) => {
const b = document.createElement('button');
b.className = 'btn small' + (primary ? ' primary' : '');
b.textContent = label;
b.addEventListener('click', async () => {
try { await api('POST', `/api/v1/tasks/${t.id}/${path}`, {}); load(); }
catch (e) { toast(e.message, 'err'); }
});
actions.appendChild(b);
};
if (t.status === 'assigned' || t.status === 'changes_requested') act('Start', 'start', true);
if (t.status === 'in_progress') act('Submit for review', 'submit-review', true);
if (['assigned', 'in_progress'].includes(t.status)) act('Abandon', 'abandon');
return el;
}
subscribe('board', () => setTimeout(load, 300));
onPollFallback(load);
load();
+41
View File
@@ -0,0 +1,41 @@
import { api, toast } from '/static/js/api.js';
const host = document.getElementById('notification-page-list');
const errorBox = document.getElementById('error');
let cursor = '';
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
async function load(reset) {
try {
if (reset) { cursor = ''; host.replaceChildren(); }
const res = await api('GET', `/api/v1/notifications?limit=30&cursor=${cursor}`);
const items = res.notifications;
cursor = items.length ? items[items.length - 1].id : cursor;
host.append(...items.map((n) => {
const div = document.createElement('div');
div.className = 'card';
div.style.padding = '12px';
div.innerHTML = `
<div class="spread">
<a href="${esc(n.link || '#')}"><strong>${esc(n.title)}</strong></a>
<span class="muted">${new Date(n.createdAt).toLocaleString()} ${n.readAt ? '' : '· <span class="badge accent">new</span>'}</span>
</div>
${n.body ? `<p class="muted" style="margin:4px 0 0">${esc(n.body)}</p>` : ''}`;
return div;
}));
document.getElementById('notif-more').hidden = items.length < 30;
} catch (e) { errorBox.textContent = e.message; }
}
document.getElementById('mark-all').addEventListener('click', async () => {
try {
await api('POST', '/api/v1/notifications/read', { ids: [] });
toast('All notifications marked read.', 'ok');
load(true);
} catch (e) { toast(e.message, 'err'); }
});
document.getElementById('notif-more').addEventListener('click', () => load(false));
load(true);
+55
View File
@@ -0,0 +1,55 @@
// Global notifications: bell with unread badge, dropdown, and WS toasts for
// every lifecycle transition (§11.1).
import { api, toast } from '/static/js/api.js';
import { subscribe, onPollFallback } from '/static/js/ws.js';
if (document.body.dataset.loggedIn === '1') {
const bell = document.getElementById('nav-bell');
const badge = document.getElementById('nav-bell-count');
const panel = document.getElementById('notif-panel');
const list = document.getElementById('notif-list');
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
async function refresh() {
try {
const res = await api('GET', '/api/v1/notifications?limit=15');
badge.textContent = res.unread > 0 ? String(res.unread) : '';
badge.hidden = res.unread === 0;
list.replaceChildren(...res.notifications.map((n) => {
const li = document.createElement('li');
li.innerHTML = `<a href="${esc(n.link || '#')}">
<strong>${esc(n.title)}</strong>${n.readAt ? '' : ' <span class="badge accent">new</span>'}<br>
<span class="muted">${esc(n.body || '')}</span></a>`;
return li;
}));
if (!res.notifications.length) {
list.innerHTML = '<li class="muted">No notifications.</li>';
}
} catch (e) { /* signed out or transient */ }
}
bell.addEventListener('click', async () => {
panel.hidden = !panel.hidden;
if (!panel.hidden) {
await refresh();
try { await api('POST', '/api/v1/notifications/read', { ids: [] }); } catch (e) { /* ignore */ }
badge.hidden = true;
badge.textContent = '';
}
});
document.addEventListener('click', (e) => {
if (!panel.hidden && !panel.contains(e.target) && e.target !== bell) panel.hidden = true;
});
subscribe('notifications', (event, n) => {
if (event === 'notification' && n) {
toast(n.title, 'ok');
refresh();
}
});
onPollFallback(refresh);
refresh();
}
+56
View File
@@ -0,0 +1,56 @@
import { api, toast } from '/static/js/api.js';
const errorBox = document.getElementById('error');
let developers = [];
async function load() {
try {
const res = await api('GET', '/api/v1/consultant/pool');
developers = res.developers;
render();
} catch (e) { errorBox.textContent = e.message; }
}
function render() {
const filter = document.getElementById('pool-search').value.trim().toLowerCase();
const tbody = document.querySelector('#pool-table tbody');
tbody.replaceChildren(...developers
.filter((d) => !filter || d.name.toLowerCase().includes(filter) || d.email.toLowerCase().includes(filter))
.map((d) => {
const tr = document.createElement('tr');
const avatar = document.createElement('td');
if (d.avatarFileId) {
const img = document.createElement('img');
img.className = 'avatar';
img.src = `/files/${d.avatarFileId}`;
img.alt = '';
avatar.appendChild(img);
}
const name = document.createElement('td');
name.textContent = d.name;
name.dataset.userCard = d.id;
const email = document.createElement('td');
email.textContent = d.email;
const bio = document.createElement('td');
bio.textContent = (d.bio || '').slice(0, 80);
bio.className = 'muted';
const action = document.createElement('td');
const btn = document.createElement('button');
btn.className = d.inPool ? 'btn small' : 'btn small primary';
btn.textContent = d.inPool ? 'Remove from pool' : 'Add to pool';
btn.addEventListener('click', async () => {
try {
if (d.inPool) await api('DELETE', `/api/v1/consultant/pool/${d.id}`);
else await api('POST', '/api/v1/consultant/pool', { developerId: d.id });
d.inPool = !d.inPool;
render();
} catch (e) { toast(e.message, 'err'); }
});
action.appendChild(btn);
tr.append(avatar, name, email, bio, action);
return tr;
}));
}
document.getElementById('pool-search').addEventListener('input', render);
load();
+36
View File
@@ -0,0 +1,36 @@
import { api } from '/static/js/api.js';
import { subscribe, onPollFallback } from '/static/js/ws.js';
const host = document.getElementById('review-list');
const errorBox = document.getElementById('error');
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
async function load() {
try {
const res = await api('GET', '/api/v1/consultant/reviews');
host.replaceChildren(...res.tasks.map((t) => {
const card = document.createElement('div');
card.className = 'card';
const who = t.assignee
? (t.assignee.kind === 'ai' ? 'AI work performer' : 'developer')
: 'unknown';
card.innerHTML = `
<div class="spread">
<h3><a href="/tasks/${t.id}">${esc(t.title)}</a></h3>
<span class="badge accent">◈ ${t.bounty}</span>
</div>
<p class="muted">submitted by ${who} · updated ${new Date(t.updatedAt).toLocaleString()}</p>
<a class="btn primary" href="/tasks/${t.id}">Open review</a>`;
return card;
}));
if (!res.tasks.length) {
host.innerHTML = '<p class="muted">Nothing waiting for review. 🎉</p>';
}
} catch (e) { errorBox.textContent = e.message; }
}
subscribe('board', () => setTimeout(load, 300));
onPollFallback(load);
load();
+258
View File
@@ -0,0 +1,258 @@
import { api, toast } from '/static/js/api.js';
import { subscribe } from '/static/js/ws.js';
const root = document.getElementById('task-root');
const errorBox = document.getElementById('error');
const taskId = root.dataset.taskId;
const meId = root.dataset.userId;
const isConsultant = root.dataset.isConsultant === '1' || root.dataset.isAdmin === '1';
let task = null;
const userCache = new Map();
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
async function userName(id) {
if (!id || id.startsWith('system')) return id || 'system';
if (!userCache.has(id)) {
try {
const res = await api('GET', `/api/v1/users/${id}/card`);
userCache.set(id, res.card.name);
} catch (e) { userCache.set(id, id.slice(0, 8)); }
}
return userCache.get(id);
}
async function load() {
try {
const res = await api('GET', `/api/v1/tasks/${taskId}`);
task = res.task;
await render();
} catch (e) {
errorBox.textContent = e.message;
root.innerHTML = '';
}
}
const mine = () => task.assignee && task.assignee.kind === 'human' && task.assignee.userId === meId;
const myClaim = () => (task.claimRequests || []).some((c) => c.developerId === meId);
async function render() {
const t = task;
const assigneeName = t.assignee
? (t.assignee.kind === 'ai' ? `AI (job ${esc(t.assignee.jobId || '')})` : esc(await userName(t.assignee.userId)))
: '—';
const orphan = t.external && t.external.orphaned
? '<span class="badge" style="color:var(--err)">orphaned upstream</span>' : '';
const claims = await Promise.all((t.claimRequests || []).map(async (c) => `
<li>${esc(await userName(c.developerId))}${c.note ? ' — ' + esc(c.note) : ''}
${isConsultant ? `
<button class="btn small primary" data-approve="${esc(c.developerId)}">Approve</button>
<button class="btn small" data-decline="${esc(c.developerId)}">Decline</button>` : ''}
</li>`));
const comments = await Promise.all((t.comments || []).map(async (c) => `
<div class="card" style="padding:12px">
<p class="muted" style="margin:0 0 8px">${esc(await userName(c.authorId))} · ${new Date(c.at).toLocaleString()}</p>
<div>${c.body}</div>
</div>`));
const timeline = await Promise.all((t.timeline || []).slice().reverse().map(async (e) => `
<tr><td>${new Date(e.at).toLocaleString()}</td>
<td>${esc(await userName(e.actorId))}</td>
<td>${esc(e.event)}${e.data && e.data.note ? ' — ' + esc(e.data.note) : ''}</td></tr>`));
const timeRows = (t.timeLog || []).map((e) =>
`<tr><td>${new Date(e.at).toLocaleString()}</td><td>${e.minutes} min</td><td>${esc(e.note)}</td></tr>`).join('');
const totalMin = (t.timeLog || []).reduce((a, e) => a + e.minutes, 0);
root.innerHTML = `
<div class="spread">
<h1>${esc(t.title)}</h1>
<span class="badge accent" style="font-size:1.1rem">◈ ${t.bounty}</span>
</div>
<p class="muted">
<span class="badge">${esc(t.status)}</span> ${orphan}
${t.external ? `· <a href="${esc(t.external.url)}" target="_blank" rel="noopener">${esc(t.external.key)} ↗</a>` : ''}
· assignee: ${assigneeName}
${totalMin ? `· time logged: ${Math.floor(totalMin / 60)}h ${totalMin % 60}m` : ''}
</p>
<div id="actions" class="mt"></div>
<div class="card mt">
<h2>Description</h2>
<p style="white-space:pre-wrap">${esc(t.description)}</p>
${(t.acceptanceCriteria || []).length ? `
<h3>Acceptance criteria</h3>
<ul>${t.acceptanceCriteria.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>` : ''}
${(t.attachments || []).length ? `
<h3>Attachments</h3>
<ul>${t.attachments.map((a) => `<li><a href="${a.fileId ? '/files/' + a.fileId : esc(a.url)}" target="_blank" rel="noopener">${esc(a.name)}</a></li>`).join('')}</ul>` : ''}
${(t.links || []).length ? `
<h3>Links</h3>
<ul>${t.links.map((l) => `<li><a href="${esc(l)}" target="_blank" rel="noopener">${esc(l)}</a></li>`).join('')}</ul>` : ''}
</div>
${(t.claimRequests || []).length ? `
<div class="card mt"><h2>Assignment requests</h2><ul>${claims.join('')}</ul></div>` : ''}
<div class="card mt">
<h2>Comments</h2>
<div class="stack">${comments.join('') || '<p class="muted">No comments yet.</p>'}</div>
<form id="comment-form" class="mt">
<div class="field">
<label for="comment-body">Add comment (mention with @name or @email)</label>
<textarea id="comment-body" required></textarea>
</div>
<button class="btn primary" type="submit">Comment</button>
</form>
</div>
${mine() ? `
<div class="card mt">
<h2>Log time</h2>
<form id="time-form" class="row">
<div class="field"><label for="time-minutes">Minutes</label>
<input type="number" id="time-minutes" min="1" max="1440" required></div>
<div class="field"><label for="time-note">Note</label>
<input type="text" id="time-note"></div>
<div class="field" style="flex:0;align-self:flex-end">
<button class="btn primary" type="submit">Log</button></div>
</form>
${timeRows ? `<table class="list"><thead><tr><th>When</th><th>Time</th><th>Note</th></tr></thead><tbody>${timeRows}</tbody></table>` : ''}
</div>` : ''}
<div class="card mt">
<h2>Timeline</h2>
<table class="list"><thead><tr><th>When</th><th>Who</th><th>Event</th></tr></thead>
<tbody>${timeline.join('')}</tbody></table>
</div>`;
renderActions();
wireHandlers();
}
function renderActions() {
const host = document.getElementById('actions');
const t = task;
const add = (label, fn, primary) => {
const b = document.createElement('button');
b.className = 'btn' + (primary ? ' primary' : '');
b.style.marginRight = '8px';
b.textContent = label;
b.addEventListener('click', fn);
host.appendChild(b);
};
const post = (path, body = {}) => async () => {
try { await api('POST', `/api/v1/tasks/${taskId}/${path}`, body); load(); }
catch (e) { toast(e.message, 'err'); }
};
if (root.dataset.isDeveloper === '1') {
if (t.status === 'published' && !myClaim()) add('Request assignment', post('claim', { note: '' }), true);
if (myClaim() && ['published', 'claim_requested'].includes(t.status)) add('Withdraw request', post('claim/withdraw'));
if (mine() && ['assigned', 'changes_requested'].includes(t.status)) add('Start work', post('start'), true);
if (mine() && t.status === 'in_progress') add('Submit for review', post('submit-review'), true);
if (mine() && ['assigned', 'in_progress'].includes(t.status)) add('Abandon', post('abandon'));
}
if (isConsultant) {
if (t.status === 'in_review') {
add('Review…', openReview, true);
}
if (t.status === 'published') add('Assign to AI…', () => document.getElementById('ai-dialog').showModal(), false);
if (['assigned', 'in_progress'].includes(t.status)) add('Unassign', post('unassign'));
if (t.status !== 'archived' && t.status !== 'approved') add('Archive', post('archive'));
}
}
function wireHandlers() {
document.querySelectorAll('[data-approve]').forEach((b) => {
b.addEventListener('click', async () => {
try {
await api('POST', `/api/v1/tasks/${taskId}/approve-claim`, { developerId: b.dataset.approve });
load();
} catch (e) { toast(e.message, 'err'); }
});
});
document.querySelectorAll('[data-decline]').forEach((b) => {
b.addEventListener('click', async () => {
try {
await api('POST', `/api/v1/tasks/${taskId}/decline-claim`, { developerId: b.dataset.decline });
load();
} catch (e) { toast(e.message, 'err'); }
});
});
document.getElementById('comment-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = document.getElementById('comment-body').value;
try {
await api('POST', `/api/v1/tasks/${taskId}/comments`, { body: '<p>' + esc(body).replace(/\n/g, '<br>') + '</p>' });
load();
} catch (err) { toast(err.message, 'err'); }
});
const timeForm = document.getElementById('time-form');
if (timeForm) {
timeForm.addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api('POST', `/api/v1/tasks/${taskId}/time`, {
minutes: parseInt(document.getElementById('time-minutes').value, 10),
note: document.getElementById('time-note').value.trim(),
});
load();
} catch (err) { toast(err.message, 'err'); }
});
}
}
// ---- review dialog with per-AC checklist (§11.18) ----
const reviewDlg = document.getElementById('review-dialog');
function openReview() {
const host = document.getElementById('review-checklist');
host.replaceChildren(...(task.acceptanceCriteria || []).map((c, i) => {
const div = document.createElement('div');
div.innerHTML = `<label style="font-weight:normal">
<input type="checkbox" data-ac="${i}" checked> ${esc(c)}</label>`;
return div;
}));
reviewDlg.showModal();
}
document.getElementById('review-cancel').addEventListener('click', () => reviewDlg.close());
async function submitReview(decision) {
const checklist = [...document.querySelectorAll('[data-ac]')].map((cb, i) => ({
criterion: task.acceptanceCriteria[i], ok: cb.checked,
}));
try {
await api('POST', `/api/v1/tasks/${taskId}/review`, {
decision, note: document.getElementById('review-note').value.trim(), checklist,
});
reviewDlg.close();
toast(decision === 'approve' ? 'Approved — bounty awarded.' : 'Changes requested.', 'ok');
load();
} catch (e) { toast(e.message, 'err'); }
}
document.getElementById('review-approve').addEventListener('click', () => submitReview('approve'));
document.getElementById('review-changes').addEventListener('click', () => submitReview('request_changes'));
// ---- AI dialog ----
const aiDlg = document.getElementById('ai-dialog');
document.getElementById('ai-cancel').addEventListener('click', () => aiDlg.close());
document.getElementById('ai-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api('POST', `/api/v1/tasks/${taskId}/assign-ai`, {
context: {
repositoryUrl: document.getElementById('ai-repo').value.trim(),
branch: document.getElementById('ai-branch').value.trim(),
instructions: document.getElementById('ai-instructions').value.trim(),
},
});
aiDlg.close();
toast('AI job created — the result will arrive for review.', 'ok');
load();
} catch (err) { toast(err.message, 'err'); }
});
subscribe('board', (event, data) => {
if (data && data.taskId === taskId) setTimeout(load, 200);
});
load();
+46
View File
@@ -0,0 +1,46 @@
{{define "content"}}
<h1>Bounty Board</h1>
<div class="error-box" id="error" role="alert"></div>
<div class="row mt" id="board-filters">
<div class="field">
<label for="f-q">Search</label>
<input type="search" id="f-q" placeholder="Search title or description… ( / )">
</div>
<div class="field">
<label for="f-customer">Customer</label>
<select id="f-customer"><option value="">All</option></select>
</div>
<div class="field">
<label for="f-min">Min bounty</label>
<input type="number" id="f-min" min="0" step="10" placeholder="0">
</div>
<div class="field">
<label for="f-sort">Sort</label>
<select id="f-sort">
<option value="">Newest</option>
<option value="bounty">Highest bounty</option>
</select>
</div>
<div class="field" style="flex:0;align-self:flex-end">
<button class="btn" id="f-save" title="Save current filters as default">Save</button>
</div>
</div>
<div class="grid mt" id="board-grid"></div>
<dialog id="claim-dialog">
<form method="dialog" id="claim-form" class="stack" style="min-width:380px">
<h2>Request assignment</h2>
<p class="muted" id="claim-task-title"></p>
<div class="field">
<label for="claim-note">Pitch note (optional)</label>
<textarea id="claim-note" placeholder="why you?"></textarea>
</div>
<div class="spread">
<button class="btn" type="button" id="claim-cancel">Cancel</button>
<button class="btn primary" type="submit">Request assignment</button>
</div>
</form>
</dialog>
{{end}}
+9
View File
@@ -30,6 +30,14 @@
</div> </div>
<div class="right"> <div class="right">
<button class="btn ghost small" id="nav-theme" type="button" aria-label="Toggle dark / light theme"></button> <button class="btn ghost small" id="nav-theme" type="button" aria-label="Toggle dark / light theme"></button>
{{if .User}}
<span style="position:relative">
<button class="btn ghost small" id="nav-bell" type="button" aria-label="Notifications">🔔<span class="bell-badge" id="nav-bell-count" hidden></span></button>
<div id="notif-panel" class="notif-panel" hidden>
<ul id="notif-list" class="notif-list"></ul>
<a href="/notifications" class="muted">All notifications →</a>
</div>
</span>{{end}}
{{if .User}} {{if .User}}
<a href="/profile" aria-label="Your profile"> <a href="/profile" aria-label="Your profile">
{{if .User.AvatarFileID}} {{if .User.AvatarFileID}}
@@ -49,6 +57,7 @@
</main> </main>
<div id="toasts" aria-live="polite"></div> <div id="toasts" aria-live="polite"></div>
<script type="module" src="/static/js/nav.js"></script> <script type="module" src="/static/js/nav.js"></script>
{{if .User}}<script type="module" src="/static/js/notifications.js"></script>{{end}}
{{range .Scripts}}<script type="module" src="{{.}}"></script> {{range .Scripts}}<script type="module" src="{{.}}"></script>
{{end}} {{end}}
</body> </body>
+10
View File
@@ -0,0 +1,10 @@
{{define "content"}}
<h1>My Tasks</h1>
<div class="error-box" id="error" role="alert"></div>
<div class="kanban mt" id="kanban">
<section class="kanban-col" data-col="assigned"><h2>Assigned</h2><div class="stack"></div></section>
<section class="kanban-col" data-col="in_progress"><h2>In progress</h2><div class="stack"></div></section>
<section class="kanban-col" data-col="in_review"><h2>In review</h2><div class="stack"></div></section>
<section class="kanban-col" data-col="approved"><h2>Done</h2><div class="stack"></div></section>
</div>
{{end}}
+9
View File
@@ -0,0 +1,9 @@
{{define "content"}}
<div class="spread">
<h1>Notifications</h1>
<button class="btn small" id="mark-all">Mark all read</button>
</div>
<div class="error-box" id="error" role="alert"></div>
<div id="notification-page-list" class="stack mt"></div>
<button class="btn mt" id="notif-more">Load more</button>
{{end}}
+12
View File
@@ -0,0 +1,12 @@
{{define "content"}}
<h1>Developer Pool</h1>
<p class="hint">Developers in your pool see your published tasks on their bounty board.</p>
<div class="error-box" id="error" role="alert"></div>
<div class="spread mt">
<input type="search" id="pool-search" placeholder="Filter developers…" style="max-width:280px" aria-label="Filter developers">
</div>
<table class="list mt" id="pool-table">
<thead><tr><th></th><th>Name</th><th>Email</th><th>Bio</th><th></th></tr></thead>
<tbody></tbody>
</table>
{{end}}
+5
View File
@@ -0,0 +1,5 @@
{{define "content"}}
<h1>Review Queue</h1>
<div class="error-box" id="error" role="alert"></div>
<div id="review-list" class="stack mt"></div>
{{end}}
+49
View File
@@ -0,0 +1,49 @@
{{define "content"}}
<div class="error-box" id="error" role="alert"></div>
<div id="task-root" data-task-id="{{.Data.TaskID}}" data-user-id="{{.User.ID}}"
data-is-consultant="{{if .User.Roles.Consultant}}1{{end}}"
data-is-admin="{{if .User.Roles.Admin}}1{{end}}"
data-is-developer="{{if .User.Roles.Developer}}1{{end}}">
<p class="muted">Loading task…</p>
</div>
<dialog id="review-dialog">
<form method="dialog" id="review-form" class="stack" style="min-width:460px">
<h2>Review submission</h2>
<div id="review-checklist" class="stack"></div>
<div class="field">
<label for="review-note">Review note</label>
<textarea id="review-note"></textarea>
</div>
<div class="spread">
<button class="btn" type="button" id="review-cancel">Cancel</button>
<span>
<button class="btn danger" type="button" id="review-changes">Request changes</button>
<button class="btn primary" type="button" id="review-approve">Approve &amp; award bounty</button>
</span>
</div>
</form>
</dialog>
<dialog id="ai-dialog">
<form method="dialog" id="ai-form" class="stack" style="min-width:420px">
<h2>Assign to AI work performer</h2>
<div class="field">
<label for="ai-repo">Repository URL (optional)</label>
<input type="text" id="ai-repo" placeholder="https://github.com/org/repo.git">
</div>
<div class="field">
<label for="ai-branch">Branch (optional)</label>
<input type="text" id="ai-branch" placeholder="main">
</div>
<div class="field">
<label for="ai-instructions">Extra instructions (optional)</label>
<textarea id="ai-instructions"></textarea>
</div>
<div class="spread">
<button class="btn" type="button" id="ai-cancel">Cancel</button>
<button class="btn primary" type="submit">Create AI job</button>
</div>
</form>
</dialog>
{{end}}