From 70a813edfab55c83cfc792c08481769cc83b0ba8 Mon Sep 17 00:00:00 2001 From: etalon Date: Fri, 12 Jun 2026 20:11:05 +0200 Subject: [PATCH] feat: bounty board, task lifecycle, AI work performer flow, notifications (phase 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- PROGRESS.md | 1 + cmd/app/main.go | 7 + internal/chat/sanitize.go | 168 +++++++ internal/chat/sanitize_test.go | 79 ++++ internal/http/board.go | 486 ++++++++++++++++++++ internal/http/consultant.go | 358 ++++++++++++++ internal/http/lifecycle_integration_test.go | 445 ++++++++++++++++++ internal/http/server.go | 27 +- internal/http/web.go | 35 +- internal/http/workresults.go | 141 ++++++ internal/store/awards.go | 31 ++ internal/store/pools.go | 77 ++++ internal/workperform/client.go | 112 +++++ web/static/css/app.css | 27 ++ web/static/js/board.js | 144 ++++++ web/static/js/my-tasks.js | 54 +++ web/static/js/notifications-page.js | 41 ++ web/static/js/notifications.js | 55 +++ web/static/js/pool.js | 56 +++ web/static/js/reviews.js | 36 ++ web/static/js/task.js | 258 +++++++++++ web/templates/board.html | 46 ++ web/templates/layout.html | 9 + web/templates/my-tasks.html | 10 + web/templates/notifications.html | 9 + web/templates/pool.html | 12 + web/templates/reviews.html | 5 + web/templates/task.html | 49 ++ 28 files changed, 2764 insertions(+), 14 deletions(-) create mode 100644 internal/chat/sanitize.go create mode 100644 internal/chat/sanitize_test.go create mode 100644 internal/http/board.go create mode 100644 internal/http/consultant.go create mode 100644 internal/http/lifecycle_integration_test.go create mode 100644 internal/http/workresults.go create mode 100644 internal/store/awards.go create mode 100644 internal/store/pools.go create mode 100644 internal/workperform/client.go create mode 100644 web/static/js/board.js create mode 100644 web/static/js/my-tasks.js create mode 100644 web/static/js/notifications-page.js create mode 100644 web/static/js/notifications.js create mode 100644 web/static/js/pool.js create mode 100644 web/static/js/reviews.js create mode 100644 web/static/js/task.js create mode 100644 web/templates/board.html create mode 100644 web/templates/my-tasks.html create mode 100644 web/templates/notifications.html create mode 100644 web/templates/pool.html create mode 100644 web/templates/reviews.html create mode 100644 web/templates/task.html diff --git a/PROGRESS.md b/PROGRESS.md index 0996631..4a6c111 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 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 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. diff --git a/cmd/app/main.go b/cmd/app/main.go index 3c17fc1..411010e 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -24,6 +24,7 @@ import ( "bountyboard/internal/metrics" "bountyboard/internal/store" syncpkg "bountyboard/internal/sync" + "bountyboard/internal/workperform" "bountyboard/internal/ws" ) @@ -98,6 +99,12 @@ func run() error { srv.SetAtomizerInfo(atomClient) 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. runner := jobs.NewRunner(st, log, reg, 4) atomSvc := atomize.NewService(st, atomClient, log, cfg.AppBaseURL, diff --git a/internal/chat/sanitize.go b/internal/chat/sanitize.go new file mode 100644 index 0000000..a4c609a --- /dev/null +++ b/internal/chat/sanitize.go @@ -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 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 ), 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("") + } + break + } + } + continue + } + if voidTags[name] { + out.WriteString("<" + name + ">") + continue + } + if name == "a" { + href := safeHref(attrs) + if href == "" { + out.WriteString("") + } else { + out.WriteString(``) + } + } else { + out.WriteString("<" + name + ">") + } + stack = append(stack, name) + } + // balance anything left open + for n := len(stack) - 1; n >= 0; n-- { + out.WriteString("") + } + 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() +} diff --git a/internal/chat/sanitize_test.go b/internal/chat/sanitize_test.go new file mode 100644 index 0000000..c4d157d --- /dev/null +++ b/internal/chat/sanitize_test.go @@ -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 & "friends"`, "hello & "friends""}, + {"allowed formatting kept", "bold it u s", "bold it u s"}, + {"paragraph and lists", "

a

  • x
  • y
", "

a

  • x
  • y
"}, + {"code and pre", "
x := 1
", "
x := 1
"}, + {"blockquote", "
q
", "
q
"}, + {"br void", "a
b
c", "a
b
c"}, + {"script stripped", `hi`, "alert(1)hi"}, + {"img dropped", `safe`, "safe"}, + {"event handlers stripped from allowed tag", `x`, "x"}, + {"style attr stripped", `

x

`, "

x

"}, + {"https link kept with rel", `
l`, + `l`}, + {"http link kept", `l`, + `l`}, + {"javascript href removed", `l`, "l"}, + {"data href removed", `l`, "l"}, + {"single-quoted js href removed", `l`, "l"}, + {"unquoted href", `l`, + `l`}, + {"unclosed tags balanced", "bold both", "bold both"}, + {"stray close ignored", "text", "text"}, + {"mismatched nesting closed in order", "x", "x"}, + {"case insensitive tags", "x", "xy"}, + {"nested quotes attack", `">x`, + `alert(1)">x`}, + {"incomplete tag escaped", "a alert(1)`, + ``, + ``, + ``, + `x`, + `x`, + `x`, + `</script>`, + `

`, + `
`, + } + for _, v := range vectors { + got := strings.ToLower(SanitizeHTML(v)) + for _, bad := range []string{"x & `); got != "x & y" { + t.Errorf("SanitizePlain = %q", got) + } +} diff --git a/internal/http/board.go b/internal/http/board.go new file mode 100644 index 0000000..e3dd664 --- /dev/null +++ b/internal/http/board.go @@ -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) +} diff --git a/internal/http/consultant.go b/internal/http/consultant.go new file mode 100644 index 0000000..9d6b776 --- /dev/null +++ b/internal/http/consultant.go @@ -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) +} diff --git a/internal/http/lifecycle_integration_test.go b/internal/http/lifecycle_integration_test.go new file mode 100644 index 0000000..986fe78 --- /dev/null +++ b/internal/http/lifecycle_integration_test.go @@ -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": "

Done, see PR

"}, + 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(" 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 '14d+'; + if (days > 7) return '7d+'; + return ''; +} + +function render() { + grid.replaceChildren(...tasks.map(renderCard)); + if (!tasks.length) { + grid.innerHTML = '

No published tasks right now. Consultants add you to their pool to share work here.

'; + } +} + +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 = ` +
+ ◈ ${t.bounty} + ${ageBadge(t)} +
+

${esc(t.title)}

+

${esc((t.description || '').slice(0, 180))}

+

${(t.acceptanceCriteria || []).length} acceptance criteria

+
+ + ${myClaim ? 'requested by you' : ''} + ${others ? `${others} other request(s)` : ''} + + ${myClaim + ? '' + : ''} +
`; + 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); diff --git a/web/static/js/my-tasks.js b/web/static/js/my-tasks.js new file mode 100644 index 0000000..b28f0c0 --- /dev/null +++ b/web/static/js/my-tasks.js @@ -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('changes requested'); + el.innerHTML = ` + ${esc(t.title)} ${badges.join(' ')} +
+ ◈ ${t.bounty} + +
`; + 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(); diff --git a/web/static/js/notifications-page.js b/web/static/js/notifications-page.js new file mode 100644 index 0000000..c7d1404 --- /dev/null +++ b/web/static/js/notifications-page.js @@ -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 = ` +
+ ${esc(n.title)} + ${new Date(n.createdAt).toLocaleString()} ${n.readAt ? '' : '· new'} +
+ ${n.body ? `

${esc(n.body)}

` : ''}`; + 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); diff --git a/web/static/js/notifications.js b/web/static/js/notifications.js new file mode 100644 index 0000000..1ec428f --- /dev/null +++ b/web/static/js/notifications.js @@ -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 = ` + ${esc(n.title)}${n.readAt ? '' : ' new'}
+ ${esc(n.body || '')}
`; + return li; + })); + if (!res.notifications.length) { + list.innerHTML = '
  • No notifications.
  • '; + } + } 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(); +} diff --git a/web/static/js/pool.js b/web/static/js/pool.js new file mode 100644 index 0000000..886b749 --- /dev/null +++ b/web/static/js/pool.js @@ -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(); diff --git a/web/static/js/reviews.js b/web/static/js/reviews.js new file mode 100644 index 0000000..41f063d --- /dev/null +++ b/web/static/js/reviews.js @@ -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 = ` +
    +

    ${esc(t.title)}

    + ◈ ${t.bounty} +
    +

    submitted by ${who} · updated ${new Date(t.updatedAt).toLocaleString()}

    + Open review`; + return card; + })); + if (!res.tasks.length) { + host.innerHTML = '

    Nothing waiting for review. 🎉

    '; + } + } catch (e) { errorBox.textContent = e.message; } +} + +subscribe('board', () => setTimeout(load, 300)); +onPollFallback(load); +load(); diff --git a/web/static/js/task.js b/web/static/js/task.js new file mode 100644 index 0000000..dc1e306 --- /dev/null +++ b/web/static/js/task.js @@ -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 + ? 'orphaned upstream' : ''; + + const claims = await Promise.all((t.claimRequests || []).map(async (c) => ` +
  • ${esc(await userName(c.developerId))}${c.note ? ' — ' + esc(c.note) : ''} + ${isConsultant ? ` + + ` : ''} +
  • `)); + + const comments = await Promise.all((t.comments || []).map(async (c) => ` +
    +

    ${esc(await userName(c.authorId))} · ${new Date(c.at).toLocaleString()}

    +
    ${c.body}
    +
    `)); + + const timeline = await Promise.all((t.timeline || []).slice().reverse().map(async (e) => ` + ${new Date(e.at).toLocaleString()} + ${esc(await userName(e.actorId))} + ${esc(e.event)}${e.data && e.data.note ? ' — ' + esc(e.data.note) : ''}`)); + + const timeRows = (t.timeLog || []).map((e) => + `${new Date(e.at).toLocaleString()}${e.minutes} min${esc(e.note)}`).join(''); + const totalMin = (t.timeLog || []).reduce((a, e) => a + e.minutes, 0); + + root.innerHTML = ` +
    +

    ${esc(t.title)}

    + ◈ ${t.bounty} +
    +

    + ${esc(t.status)} ${orphan} + ${t.external ? `· ${esc(t.external.key)} ↗` : ''} + · assignee: ${assigneeName} + ${totalMin ? `· time logged: ${Math.floor(totalMin / 60)}h ${totalMin % 60}m` : ''} +

    +
    +
    +

    Description

    +

    ${esc(t.description)}

    + ${(t.acceptanceCriteria || []).length ? ` +

    Acceptance criteria

    +
      ${t.acceptanceCriteria.map((c) => `
    • ${esc(c)}
    • `).join('')}
    ` : ''} + ${(t.attachments || []).length ? ` +

    Attachments

    + ` : ''} + ${(t.links || []).length ? ` +

    Links

    + ` : ''} +
    + ${(t.claimRequests || []).length ? ` +

    Assignment requests

      ${claims.join('')}
    ` : ''} +
    +

    Comments

    +
    ${comments.join('') || '

    No comments yet.

    '}
    +
    +
    + + +
    + +
    +
    + ${mine() ? ` +
    +

    Log time

    +
    +
    +
    +
    +
    +
    +
    +
    + ${timeRows ? `${timeRows}
    WhenTimeNote
    ` : ''} +
    ` : ''} +
    +

    Timeline

    + + ${timeline.join('')}
    WhenWhoEvent
    +
    `; + + 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: '

    ' + esc(body).replace(/\n/g, '
    ') + '

    ' }); + 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 = ``; + 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(); diff --git a/web/templates/board.html b/web/templates/board.html new file mode 100644 index 0000000..a238764 --- /dev/null +++ b/web/templates/board.html @@ -0,0 +1,46 @@ +{{define "content"}} +

    Bounty Board

    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + +
    + + +
    +

    Request assignment

    +

    +
    + + +
    +
    + + +
    +
    +
    +{{end}} diff --git a/web/templates/layout.html b/web/templates/layout.html index f56b707..f3e878b 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -30,6 +30,14 @@
    + {{if .User}} + + + + {{end}} {{if .User}} {{if .User.AvatarFileID}} @@ -49,6 +57,7 @@
    + {{if .User}}{{end}} {{range .Scripts}} {{end}} diff --git a/web/templates/my-tasks.html b/web/templates/my-tasks.html new file mode 100644 index 0000000..3a218f2 --- /dev/null +++ b/web/templates/my-tasks.html @@ -0,0 +1,10 @@ +{{define "content"}} +

    My Tasks

    + +
    +

    Assigned

    +

    In progress

    +

    In review

    +

    Done

    +
    +{{end}} diff --git a/web/templates/notifications.html b/web/templates/notifications.html new file mode 100644 index 0000000..a37f3cf --- /dev/null +++ b/web/templates/notifications.html @@ -0,0 +1,9 @@ +{{define "content"}} +
    +

    Notifications

    + +
    + +
    + +{{end}} diff --git a/web/templates/pool.html b/web/templates/pool.html new file mode 100644 index 0000000..358eac3 --- /dev/null +++ b/web/templates/pool.html @@ -0,0 +1,12 @@ +{{define "content"}} +

    Developer Pool

    +

    Developers in your pool see your published tasks on their bounty board.

    + +
    + +
    + + + +
    NameEmailBio
    +{{end}} diff --git a/web/templates/reviews.html b/web/templates/reviews.html new file mode 100644 index 0000000..c176a06 --- /dev/null +++ b/web/templates/reviews.html @@ -0,0 +1,5 @@ +{{define "content"}} +

    Review Queue

    + +
    +{{end}} diff --git a/web/templates/task.html b/web/templates/task.html new file mode 100644 index 0000000..01f94c6 --- /dev/null +++ b/web/templates/task.html @@ -0,0 +1,49 @@ +{{define "content"}} + +
    +

    Loading task…

    +
    + + +
    +

    Review submission

    +
    +
    + + +
    +
    + + + + + +
    +
    +
    + + +
    +

    Assign to AI work performer

    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +{{end}}