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:
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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 & "friends""},
|
||||||
|
{"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)">x</a>`},
|
||||||
|
{"incomplete tag escaped", "a <b", "a <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 & y" {
|
||||||
|
t.Errorf("SanitizePlain = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -41,6 +42,13 @@ type Server struct {
|
|||||||
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)
|
||||||
|
|
||||||
|
|||||||
+27
-4
@@ -192,12 +192,35 @@ 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",
|
|
||||||
"/my-tasks": "My Tasks",
|
|
||||||
"/consultant/reviews": "Review Queue",
|
|
||||||
"/consultant/pool": "Developer Pool",
|
|
||||||
"/messages": "Messages",
|
"/messages": "Messages",
|
||||||
"/metrics": "Metrics",
|
"/metrics": "Metrics",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:])
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
|
}[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);
|
||||||
@@ -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) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
|
}[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();
|
||||||
@@ -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) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
|
}[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);
|
||||||
@@ -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) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
|
}[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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
|
}[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();
|
||||||
@@ -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) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
|
}[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();
|
||||||
@@ -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}}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -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}}
|
||||||
@@ -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}}
|
||||||
@@ -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}}
|
||||||
@@ -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 & 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}}
|
||||||
Reference in New Issue
Block a user