Compare commits
20 Commits
05dc91410f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 33c9c39676 | |||
| 39ec958f04 | |||
| 097d46886d | |||
| 0dcc0bc823 | |||
| c4f666209c | |||
| 9a5e302a26 | |||
| 88fa5d19c8 | |||
| c65aeb4bac | |||
| 46e2099542 | |||
| 35168b9b83 | |||
| 0676f53280 | |||
| f54c557e70 | |||
| b232b4b3d3 | |||
| 70cb664246 | |||
| b4e919502f | |||
| df08682bed | |||
| 420f045033 | |||
| 9bacb8092d | |||
| e8beb7d4f3 | |||
| 0d28f69320 |
@@ -0,0 +1,13 @@
|
||||
# Container-owned data: unreadable by the build user, and stat'ing it fails the
|
||||
# context walk outright.
|
||||
.volumes/
|
||||
backups/
|
||||
|
||||
.git/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Local working backups.
|
||||
.bak-*/
|
||||
**/.bak-*/
|
||||
@@ -24,6 +24,12 @@ ADMIN_INITIAL_PASSWORD=change-me-now
|
||||
ATOMIZER_BASE_URL=http://atomizer-mock:8090
|
||||
ATOMIZER_TOKEN=change-me-atomizer
|
||||
ATOMIZER_TIMEOUT_SEC=120
|
||||
# Optional: serve Subdivide from the Anypreta Issue Atomizer instead of the
|
||||
# §5.1 service above. Both vars are required to enable it. Extend and Summarize
|
||||
# have no counterpart in that API and always stay on ATOMIZER_BASE_URL, so the
|
||||
# service above must keep running either way.
|
||||
ATOMIZER_REMOTE_BASE_URL=
|
||||
ATOMIZER_REMOTE_API_KEY=
|
||||
WORK_PERFORMER_BASE_URL=http://work-performer:8091
|
||||
WORK_PERFORMER_TOKEN=change-me-performer
|
||||
# Job submission/polling only; jobs themselves are async.
|
||||
|
||||
@@ -3,3 +3,8 @@ bin/
|
||||
backups/
|
||||
*.test
|
||||
coverage.out
|
||||
|
||||
# Docker volume data (bind-mounted under the repo; never committed)
|
||||
/.volumes/
|
||||
.env.bak*
|
||||
.env.local
|
||||
|
||||
@@ -25,7 +25,7 @@ test-contract:
|
||||
$(GO) test -tags=contract -count=1 -timeout=8m ./internal/contract/
|
||||
|
||||
lint:
|
||||
@unformatted=$$(gofmt -l .); if [ -n "$$unformatted" ]; then \
|
||||
@unformatted=$$(gofmt -l $$(git ls-files '*.go')); if [ -n "$$unformatted" ]; then \
|
||||
echo "gofmt needed on:"; echo "$$unformatted"; exit 1; fi
|
||||
$(GO) vet ./...
|
||||
|
||||
|
||||
@@ -98,6 +98,13 @@ func run() error {
|
||||
}
|
||||
return cfg.AtomizerBaseURL
|
||||
}, cfg.AtomizerToken, cfg.AtomizerTimeout)
|
||||
if cfg.AtomizerRemoteBaseURL != "" {
|
||||
// Subdivide is served by the Anypreta Issue Atomizer; Extend and
|
||||
// Summarize have no counterpart there and stay on ATOMIZER_BASE_URL.
|
||||
atomClient.UseRemote(cfg.AtomizerRemoteBaseURL, cfg.AtomizerRemoteAPIKey, cfg.AtomizerTimeout)
|
||||
log.Info("atomizer: subdivide served by remote backend",
|
||||
"url", cfg.AtomizerRemoteBaseURL, "extend_summarize", cfg.AtomizerBaseURL)
|
||||
}
|
||||
srv.SetAtomizerInfo(atomClient)
|
||||
srv.AddReadinessCheck(httpx.ReadinessCheck{Name: "atomizer", Probe: atomClient.Healthy})
|
||||
|
||||
|
||||
+3
-6
@@ -13,7 +13,8 @@ services:
|
||||
# Mongo is reachable only on the compose network; never published to the
|
||||
# host (§12).
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
# data lives in-repo (gitignored, dot-prefixed so go tooling skips it)
|
||||
- ./.volumes/mongo:/data/db
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
||||
interval: 10s
|
||||
@@ -82,7 +83,7 @@ services:
|
||||
volumes:
|
||||
- ${HOME}/.claude:/home/node/.claude
|
||||
- ${HOME}/.claude.json:/home/node/.claude.json
|
||||
- work-data:/work
|
||||
- ./.volumes/work:/work
|
||||
ports:
|
||||
- "127.0.0.1:8091:8091"
|
||||
extra_hosts:
|
||||
@@ -93,7 +94,3 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
volumes:
|
||||
mongo-data:
|
||||
work-data:
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package atomize
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"bountyboard/internal/extsvc"
|
||||
)
|
||||
|
||||
// Backend for the Anypreta "Issue Atomizer" API (OpenAPI 3.1, v0.1.0). Its
|
||||
// model is two-level — issue → features → tasks — where ours is one-level
|
||||
// (task → children), and only *features* carry an effort weight
|
||||
// (`estimation`); its tasks carry none at all. A board child therefore maps to
|
||||
// a Feature and Subdivide calls POST /v1/atomize-issue.
|
||||
//
|
||||
// Extend and Summarize have no counterpart here (/v1/extend-feature-tasks adds
|
||||
// tasks *inside* one feature and returns the whole set), so those stay on the
|
||||
// §5.1 service. The API declares no security scheme; it authenticates with
|
||||
// Authorization: Bearer <key>.
|
||||
|
||||
// Field caps from the spec — exceeded values are 422s, so trim rather than fail.
|
||||
const (
|
||||
maxIssueLen = 50000
|
||||
maxGuidanceLen = 10000
|
||||
maxSystemIDLen = 256
|
||||
maxSystemDesc = 2000
|
||||
)
|
||||
|
||||
type apComponent struct {
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description"`
|
||||
SubComponents []apComponent `json:"sub_components,omitempty"`
|
||||
}
|
||||
|
||||
type apSystem struct {
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description"`
|
||||
Components []apComponent `json:"components,omitempty"`
|
||||
}
|
||||
|
||||
type apAtomizeIssueRequest struct {
|
||||
System apSystem `json:"system"`
|
||||
Issue string `json:"issue"`
|
||||
GuidanceNote string `json:"guidance_note,omitempty"`
|
||||
}
|
||||
|
||||
type apCriterion struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type apFeature struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
StakeholderSummary string `json:"stakeholder_summary"`
|
||||
Description string `json:"description"`
|
||||
AcceptanceCriteria []apCriterion `json:"acceptance_criteria"`
|
||||
Priority int `json:"priority"`
|
||||
RelatedComponents []string `json:"related_components"`
|
||||
DependsOn []string `json:"depends_on"`
|
||||
// Estimation is optional and nullable in the spec; observed to sum to 1
|
||||
// across a feature set, but that is not promised.
|
||||
Estimation *float64 `json:"estimation"`
|
||||
}
|
||||
|
||||
type apAtomizeIssueResponse struct {
|
||||
FeatureSet struct {
|
||||
Features []apFeature `json:"features"`
|
||||
} `json:"feature_set"`
|
||||
}
|
||||
|
||||
// remote is the Anypreta-backed Atomize path. It reuses extsvc for the bearer
|
||||
// auth, retry and circuit breaker; the base URL carries the /v1 prefix so
|
||||
// extsvc's /healthz probe lands on the API's /v1/healthz.
|
||||
type remote struct {
|
||||
c *extsvc.Client
|
||||
}
|
||||
|
||||
func newRemote(baseURL, apiKey string, timeout time.Duration) *remote {
|
||||
base := strings.TrimRight(baseURL, "/") + "/v1"
|
||||
return &remote{c: extsvc.NewClient("atomizer-remote",
|
||||
func() string { return base }, apiKey, timeout)}
|
||||
}
|
||||
|
||||
// Atomize maps a subdivide request onto POST /v1/atomize-issue and maps the
|
||||
// returned features back onto board children.
|
||||
func (r *remote) Atomize(ctx context.Context, req AtomizeRequest) (*AtomizeResponse, error) {
|
||||
body := apAtomizeIssueRequest{
|
||||
System: systemFor(req),
|
||||
Issue: truncate(issueText(req), maxIssueLen),
|
||||
GuidanceNote: truncate(guidanceNote(req), maxGuidanceLen),
|
||||
}
|
||||
var resp apAtomizeIssueResponse
|
||||
if err := r.c.Call(ctx, http.MethodPost, "/atomize-issue", body, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
features := resp.FeatureSet.Features
|
||||
if len(features) == 0 {
|
||||
return nil, fmt.Errorf("%w: empty feature set", ErrBadCoefficients)
|
||||
}
|
||||
|
||||
coeffs, estimated, err := featureCoefficients(features)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasks := make([]SubTask, len(features))
|
||||
for i, f := range features {
|
||||
criteria := make([]string, 0, len(f.AcceptanceCriteria))
|
||||
for _, c := range f.AcceptanceCriteria {
|
||||
if d := strings.TrimSpace(c.Description); d != "" {
|
||||
criteria = append(criteria, d)
|
||||
}
|
||||
}
|
||||
tasks[i] = SubTask{
|
||||
Title: strings.TrimSpace(f.Title),
|
||||
Description: strings.TrimSpace(f.Description),
|
||||
AcceptanceCriteria: criteria,
|
||||
EffortCoefficient: coeffs[i],
|
||||
}
|
||||
}
|
||||
out := &AtomizeResponse{Tasks: tasks, Model: "anypreta/issue-atomizer"}
|
||||
if !estimated {
|
||||
out.Notes = "The atomizer returned no effort estimation, so the budget was split evenly across the tasks."
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// featureCoefficients turns feature estimations into effort coefficients
|
||||
// summing to exactly 1. Estimations are optional in the spec, so an incomplete
|
||||
// set falls back to an even split (reported via the second return value)
|
||||
// rather than failing the job — bounties still need *some* weight.
|
||||
func featureCoefficients(features []apFeature) ([]float64, bool, error) {
|
||||
raw := make([]float64, len(features))
|
||||
estimated := true
|
||||
for i, f := range features {
|
||||
if f.Estimation == nil || *f.Estimation <= 0 || math.IsNaN(*f.Estimation) {
|
||||
estimated = false
|
||||
break
|
||||
}
|
||||
raw[i] = *f.Estimation
|
||||
}
|
||||
if !estimated {
|
||||
even := 1.0 / float64(len(features))
|
||||
for i := range raw {
|
||||
raw[i] = even
|
||||
}
|
||||
}
|
||||
norm, err := normalizeToOne(raw)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return norm, estimated, nil
|
||||
}
|
||||
|
||||
// normalizeToOne rescales positive weights proportionally so they sum to
|
||||
// exactly 1. Unlike NormalizeCoefficients (§5.1) it accepts any positive sum:
|
||||
// this API never promises a normalized feature set, so a deviation is expected
|
||||
// rather than a service fault.
|
||||
func normalizeToOne(weights []float64) ([]float64, error) {
|
||||
if len(weights) == 0 {
|
||||
return nil, fmt.Errorf("%w: empty", ErrBadCoefficients)
|
||||
}
|
||||
sum := 0.0
|
||||
for _, w := range weights {
|
||||
if math.IsNaN(w) || math.IsInf(w, 0) || w <= 0 {
|
||||
return nil, fmt.Errorf("%w: weight %v is not positive", ErrBadCoefficients, w)
|
||||
}
|
||||
sum += w
|
||||
}
|
||||
out := make([]float64, len(weights))
|
||||
largest, total := 0, 0.0
|
||||
for i, w := range weights {
|
||||
out[i] = math.Round(w/sum*10000) / 10000
|
||||
total += out[i]
|
||||
if out[i] > out[largest] {
|
||||
largest = i
|
||||
}
|
||||
}
|
||||
out[largest] = math.Round((out[largest]+1-total)*10000) / 10000
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// defaultComponents is the component tree we send for every customer.
|
||||
//
|
||||
// The spec marks `components` optional, but the service only emits features for
|
||||
// components it considers *in scope*: an empty tree yields zero features and a
|
||||
// 422 ("no in-scope components produced features"). The board has no component
|
||||
// model of its own, so we send a generic tree covering the usual surfaces of a
|
||||
// software change. Components irrelevant to a ticket simply produce nothing, so
|
||||
// a broad tree costs nothing and a narrow one under-splits — a single catch-all
|
||||
// component collapses every ticket into one feature.
|
||||
var defaultComponents = []apComponent{
|
||||
{ID: "frontend", Description: "User interface: pages, views, client-side behaviour and styling."},
|
||||
{ID: "backend", Description: "Server-side application logic, APIs and background jobs."},
|
||||
{ID: "data", Description: "Data storage: schema, migrations, queries and data integrity."},
|
||||
{ID: "integrations", Description: "Integrations with external systems, third-party APIs and notifications."},
|
||||
}
|
||||
|
||||
// systemFor derives the required system object from the customer, which is the
|
||||
// only project-level context the board has.
|
||||
func systemFor(req AtomizeRequest) apSystem {
|
||||
id := strings.TrimSpace(req.CustomerID)
|
||||
if id == "" {
|
||||
id = "bountyboard"
|
||||
}
|
||||
name := strings.TrimSpace(req.CustomerName)
|
||||
if name == "" {
|
||||
name = "this project"
|
||||
}
|
||||
return apSystem{
|
||||
ID: truncate(id, maxSystemIDLen),
|
||||
Description: truncate(fmt.Sprintf("Software project %q. Work is delivered as small, independently deliverable tasks picked up by freelance developers.", name), maxSystemDesc),
|
||||
Components: defaultComponents,
|
||||
}
|
||||
}
|
||||
|
||||
// issueText flattens the ticket into the single `issue` string the API takes —
|
||||
// it has no fields for acceptance criteria, links or attachments.
|
||||
func issueText(req AtomizeRequest) string {
|
||||
var b strings.Builder
|
||||
if t := strings.TrimSpace(req.Title); t != "" {
|
||||
fmt.Fprintf(&b, "%s\n\n", t)
|
||||
}
|
||||
if d := strings.TrimSpace(req.Description); d != "" {
|
||||
fmt.Fprintf(&b, "%s\n", d)
|
||||
}
|
||||
if len(req.AcceptanceCriteria) > 0 {
|
||||
b.WriteString("\nAcceptance criteria:\n")
|
||||
for _, c := range req.AcceptanceCriteria {
|
||||
fmt.Fprintf(&b, "- %s\n", c)
|
||||
}
|
||||
}
|
||||
if len(req.Links) > 0 {
|
||||
b.WriteString("\nLinks:\n")
|
||||
for _, l := range req.Links {
|
||||
fmt.Fprintf(&b, "- %s\n", l)
|
||||
}
|
||||
}
|
||||
if len(req.Attachments) > 0 {
|
||||
b.WriteString("\nAttachments:\n")
|
||||
for _, a := range req.Attachments {
|
||||
fmt.Fprintf(&b, "- %s (%s): %s\n", a.Name, a.MimeType, a.URL)
|
||||
}
|
||||
}
|
||||
s := strings.TrimSpace(b.String())
|
||||
if s == "" {
|
||||
s = "(no description provided)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// guidanceNote carries the consultant's note plus our task-count constraints,
|
||||
// which the API has no dedicated field for.
|
||||
func guidanceNote(req AtomizeRequest) string {
|
||||
parts := make([]string, 0, 2)
|
||||
if n := strings.TrimSpace(req.SubdivisionNote); n != "" {
|
||||
parts = append(parts, n)
|
||||
}
|
||||
if c := req.Constraints; c != nil {
|
||||
switch {
|
||||
case c.MinTasks > 0 && c.MaxTasks > 0:
|
||||
parts = append(parts, fmt.Sprintf("Produce between %d and %d features.", c.MinTasks, c.MaxTasks))
|
||||
case c.MinTasks > 0:
|
||||
parts = append(parts, fmt.Sprintf("Produce at least %d features.", c.MinTasks))
|
||||
case c.MaxTasks > 0:
|
||||
parts = append(parts, fmt.Sprintf("Produce at most %d features.", c.MaxTasks))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
// truncate caps a string at max bytes without splitting a rune. Byte length is
|
||||
// a safe proxy for the spec's character limits: it is never smaller.
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
cut := max
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
return s[:cut]
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package atomize
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ptr(f float64) *float64 { return &f }
|
||||
|
||||
func sum(xs []float64) float64 {
|
||||
t := 0.0
|
||||
for _, x := range xs {
|
||||
t += x
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func TestFeatureCoefficientsUsesEstimations(t *testing.T) {
|
||||
// The shape the live API returns: estimations already summing to 1.
|
||||
got, estimated, err := featureCoefficients([]apFeature{
|
||||
{Estimation: ptr(0.3636363636)},
|
||||
{Estimation: ptr(0.6363636364)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !estimated {
|
||||
t.Fatal("expected estimations to be used")
|
||||
}
|
||||
if math.Abs(sum(got)-1) > 1e-9 {
|
||||
t.Fatalf("coefficients must sum to exactly 1, got %v (sum %v)", got, sum(got))
|
||||
}
|
||||
if math.Abs(got[0]-0.3636) > 1e-9 {
|
||||
t.Fatalf("estimation not preserved: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureCoefficientsRescalesUnnormalized(t *testing.T) {
|
||||
// Nothing in the spec promises a normalized feature set.
|
||||
got, estimated, err := featureCoefficients([]apFeature{
|
||||
{Estimation: ptr(0.2)}, {Estimation: ptr(0.2)}, {Estimation: ptr(0.4)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !estimated {
|
||||
t.Fatal("expected estimations to be used")
|
||||
}
|
||||
if math.Abs(sum(got)-1) > 1e-9 {
|
||||
t.Fatalf("want sum 1, got %v (sum %v)", got, sum(got))
|
||||
}
|
||||
if math.Abs(got[2]-0.5) > 1e-9 {
|
||||
t.Fatalf("want proportional rescale (0.25/0.25/0.5), got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureCoefficientsFallsBackToEvenSplit(t *testing.T) {
|
||||
// estimation is optional and nullable — a missing one must not fail the
|
||||
// job, and must be reported so the consultant knows the split is not real.
|
||||
got, estimated, err := featureCoefficients([]apFeature{
|
||||
{Estimation: ptr(0.5)}, {Estimation: nil}, {Estimation: ptr(0.2)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if estimated {
|
||||
t.Fatal("expected fallback to even split when an estimation is missing")
|
||||
}
|
||||
if math.Abs(sum(got)-1) > 1e-9 {
|
||||
t.Fatalf("want sum 1, got %v (sum %v)", got, sum(got))
|
||||
}
|
||||
for _, c := range got {
|
||||
if math.Abs(c-1.0/3.0) > 1e-3 {
|
||||
t.Fatalf("want even split, got %v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeToOneRejectsNonPositive(t *testing.T) {
|
||||
for _, bad := range [][]float64{{0.5, 0}, {0.5, -0.1}, {math.NaN()}, {}} {
|
||||
if _, err := normalizeToOne(bad); err == nil {
|
||||
t.Fatalf("want error for %v", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueTextCarriesFieldsTheAPILacks(t *testing.T) {
|
||||
// The API takes a single `issue` string: criteria, links and attachments
|
||||
// have no fields of their own and must survive in the text.
|
||||
got := issueText(AtomizeRequest{
|
||||
Title: "Add CSV export",
|
||||
Description: "Customers cannot export.",
|
||||
AcceptanceCriteria: []string{"Downloads a .csv"},
|
||||
Links: []string{"https://tracker/1"},
|
||||
Attachments: []Attachment{{Name: "mock.png", MimeType: "image/png", URL: "https://app/f/1"}},
|
||||
})
|
||||
for _, want := range []string{"Add CSV export", "Customers cannot export.", "Downloads a .csv", "https://tracker/1", "mock.png", "https://app/f/1"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("issue text lost %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuidanceNoteFoldsInConstraints(t *testing.T) {
|
||||
got := guidanceNote(AtomizeRequest{
|
||||
SubdivisionNote: "Keep it small.",
|
||||
Constraints: &Constraints{MinTasks: 2, MaxTasks: 5},
|
||||
})
|
||||
if !strings.Contains(got, "Keep it small.") || !strings.Contains(got, "between 2 and 5") {
|
||||
t.Fatalf("want note + constraints, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateDoesNotSplitRunes(t *testing.T) {
|
||||
if got := truncate("héllo", 2); got != "h" {
|
||||
t.Fatalf("want %q, got %q", "h", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Exercises the full remote path against a stub speaking the Anypreta wire
|
||||
// format, asserting both the request we send and the children we build.
|
||||
func TestRemoteAtomizeMapsFeaturesToChildren(t *testing.T) {
|
||||
var got apAtomizeIssueRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/atomize-issue" {
|
||||
t.Errorf("wrong path: %s", r.URL.Path)
|
||||
}
|
||||
if auth := r.Header.Get("Authorization"); auth != "Bearer k" {
|
||||
t.Errorf("wrong auth header: %q", auth)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Errorf("decode: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"feature_set":{"features":[
|
||||
{"id":"f1","title":"UI","stakeholder_summary":"s","description":"d1",
|
||||
"acceptance_criteria":[{"description":"c1"},{"description":"c2"}],
|
||||
"priority":1,"related_components":["web"],"estimation":0.25},
|
||||
{"id":"f2","title":"API","stakeholder_summary":"s","description":"d2",
|
||||
"acceptance_criteria":[{"description":"c3"}],
|
||||
"priority":2,"related_components":["api"],"estimation":0.75}]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := newRemote(srv.URL, "k", 5*time.Second).Atomize(t.Context(), AtomizeRequest{
|
||||
TaskID: "t1", Title: "Export", Description: "desc",
|
||||
CustomerID: "cust1", CustomerName: "Acme",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Atomize: %v", err)
|
||||
}
|
||||
if got.System.ID != "cust1" || !strings.Contains(got.System.Description, "Acme") {
|
||||
t.Errorf("system not derived from customer: %+v", got.System)
|
||||
}
|
||||
// An empty component tree makes the live service return zero features
|
||||
// (422), so never let the tree go missing.
|
||||
if len(got.System.Components) == 0 {
|
||||
t.Error("system.components must not be empty: the service only emits features for in-scope components")
|
||||
}
|
||||
if len(resp.Tasks) != 2 {
|
||||
t.Fatalf("want 2 children, got %d", len(resp.Tasks))
|
||||
}
|
||||
if resp.Tasks[0].Title != "UI" || resp.Tasks[0].Description != "d1" {
|
||||
t.Errorf("bad child mapping: %+v", resp.Tasks[0])
|
||||
}
|
||||
if len(resp.Tasks[0].AcceptanceCriteria) != 2 || resp.Tasks[0].AcceptanceCriteria[0] != "c1" {
|
||||
t.Errorf("acceptance criteria not mapped: %+v", resp.Tasks[0].AcceptanceCriteria)
|
||||
}
|
||||
if resp.Tasks[0].EffortCoefficient != 0.25 || resp.Tasks[1].EffortCoefficient != 0.75 {
|
||||
t.Errorf("estimation not mapped to effort coefficient: %v / %v",
|
||||
resp.Tasks[0].EffortCoefficient, resp.Tasks[1].EffortCoefficient)
|
||||
}
|
||||
if resp.Notes != "" {
|
||||
t.Errorf("no even-split note expected when estimations are present: %q", resp.Notes)
|
||||
}
|
||||
}
|
||||
|
||||
// A remote-backed client must still serve Extend and Summarize from the §5.1
|
||||
// service — the Anypreta API has no counterpart for either.
|
||||
func TestRemoteDoesNotHijackExtend(t *testing.T) {
|
||||
var hit string
|
||||
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hit = r.URL.Path
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"task":{"title":"t","description":"d","acceptanceCriteria":[],"effortCoefficient":0.5}}`))
|
||||
}))
|
||||
defer mock.Close()
|
||||
|
||||
c := New(func() string { return mock.URL }, "tok", 5*time.Second)
|
||||
c.UseRemote("http://remote.invalid", "k", 5*time.Second)
|
||||
|
||||
if _, err := c.Extend(t.Context(), ExtendRequest{TaskID: "t1", ExtensionNote: "more"}); err != nil {
|
||||
t.Fatalf("Extend: %v", err)
|
||||
}
|
||||
if hit != "/v1/extend" {
|
||||
t.Fatalf("Extend must go to the §5.1 service, hit %q", hit)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,11 @@ type AtomizeRequest struct {
|
||||
Links []string `json:"links"`
|
||||
SubdivisionNote string `json:"subdivisionNote,omitempty"`
|
||||
Constraints *Constraints `json:"constraints,omitempty"`
|
||||
// CustomerID / CustomerName describe the project the ticket belongs to.
|
||||
// The §5.1 service ignores them; the Anypreta backend requires them to
|
||||
// build its mandatory `system` object.
|
||||
CustomerID string `json:"customerId,omitempty"`
|
||||
CustomerName string `json:"customerName,omitempty"`
|
||||
}
|
||||
|
||||
type ExtendRequest struct {
|
||||
@@ -70,6 +75,10 @@ var ErrBadCoefficients = errors.New("atomizer returned invalid effort coefficien
|
||||
|
||||
type Client struct {
|
||||
c *extsvc.Client
|
||||
// remote, when set, serves Atomize from the Anypreta Issue Atomizer
|
||||
// instead of the §5.1 service. Extend and Summarize have no counterpart
|
||||
// there and always go to c. See anypreta.go.
|
||||
remote *remote
|
||||
}
|
||||
|
||||
// New builds the client. baseURL is resolved per call so the admin settings
|
||||
@@ -78,13 +87,46 @@ func New(baseURL func() string, token string, timeout time.Duration) *Client {
|
||||
return &Client{c: extsvc.NewClient("atomizer", baseURL, token, timeout)}
|
||||
}
|
||||
|
||||
func (a *Client) BreakerState() string { return a.c.BreakerState() }
|
||||
func (a *Client) Healthy(ctx context.Context) error { return a.c.Healthy(ctx) }
|
||||
// UseRemote routes Atomize to the Anypreta Issue Atomizer at baseURL.
|
||||
func (a *Client) UseRemote(baseURL, apiKey string, timeout time.Duration) {
|
||||
a.remote = newRemote(baseURL, apiKey, timeout)
|
||||
}
|
||||
|
||||
// RemoteEnabled reports whether Subdivide is served by the Anypreta backend.
|
||||
func (a *Client) RemoteEnabled() bool { return a.remote != nil }
|
||||
|
||||
func (a *Client) BreakerState() string {
|
||||
if a.remote != nil {
|
||||
if s := a.remote.c.BreakerState(); s != "closed" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return a.c.BreakerState()
|
||||
}
|
||||
|
||||
// Healthy probes every backend an atomization can touch, so readiness and the
|
||||
// admin service panel go degraded if either half is down.
|
||||
func (a *Client) Healthy(ctx context.Context) error {
|
||||
if err := a.c.Healthy(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.remote != nil {
|
||||
if err := a.remote.c.Healthy(ctx); err != nil {
|
||||
return fmt.Errorf("anypreta atomizer: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Atomize calls POST /v1/atomize and applies the §5.1 contract: coefficients
|
||||
// must sum to 1 ± 0.001; a deviation ≤ 0.05 is defensively renormalized,
|
||||
// anything worse is rejected as a service error.
|
||||
// anything worse is rejected as a service error. With a remote backend
|
||||
// configured the call is served by the Anypreta API instead, which normalizes
|
||||
// its own feature estimations.
|
||||
func (a *Client) Atomize(ctx context.Context, req AtomizeRequest) (*AtomizeResponse, error) {
|
||||
if a.remote != nil {
|
||||
return a.remote.Atomize(ctx, req)
|
||||
}
|
||||
var resp AtomizeResponse
|
||||
if err := a.c.Call(ctx, http.MethodPost, "/v1/atomize", req, &resp); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -99,6 +99,15 @@ func (s *Service) HandleSubdivide(ctx context.Context, job *jobs.Job) error {
|
||||
Attachments: s.requestAttachments(t),
|
||||
Links: taskLinks(t),
|
||||
SubdivisionNote: p.Note,
|
||||
CustomerID: t.CustomerID,
|
||||
}
|
||||
// Project context for backends that need it (the Anypreta `system`
|
||||
// object). Not worth failing an atomization over.
|
||||
if cust, err := s.st.CustomerByID(ctx, t.CustomerID); err == nil {
|
||||
req.CustomerName = cust.Name
|
||||
} else {
|
||||
s.log.Warn("subdivide: customer lookup failed, sending id only",
|
||||
"task", t.ID, "customer", t.CustomerID, "err", err)
|
||||
}
|
||||
if p.MinTasks > 0 || p.MaxTasks > 0 {
|
||||
req.Constraints = &Constraints{MinTasks: p.MinTasks, MaxTasks: p.MaxTasks}
|
||||
|
||||
@@ -31,10 +31,10 @@ func SanitizeHTML(in string) string {
|
||||
if c != '<' {
|
||||
j := strings.IndexByte(in[i:], '<')
|
||||
if j < 0 {
|
||||
out.WriteString(html.EscapeString(in[i:]))
|
||||
out.WriteString(escapeText(in[i:]))
|
||||
break
|
||||
}
|
||||
out.WriteString(html.EscapeString(in[i : i+j]))
|
||||
out.WriteString(escapeText(in[i : i+j]))
|
||||
i += j
|
||||
continue
|
||||
}
|
||||
@@ -53,6 +53,31 @@ func SanitizeHTML(in string) string {
|
||||
name, attrs := splitTag(raw)
|
||||
name = strings.ToLower(name)
|
||||
|
||||
// @-mention tokens: <span class="mention" data-user-card="ULID">@Name</span>.
|
||||
// Only this exact shape is allowed; any other span is dropped.
|
||||
if name == "span" {
|
||||
if closing {
|
||||
for n := len(stack) - 1; n >= 0; n-- {
|
||||
if stack[n] == "span" {
|
||||
for len(stack) > n {
|
||||
top := stack[len(stack)-1]
|
||||
stack = stack[:len(stack)-1]
|
||||
out.WriteString("</" + top + ">")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
uid := mentionUID(attrs)
|
||||
if uid == "" {
|
||||
continue // drop the opening span, keep its text
|
||||
}
|
||||
out.WriteString(`<span class="mention" data-user-card="` + html.EscapeString(uid) + `">`)
|
||||
stack = append(stack, "span")
|
||||
continue
|
||||
}
|
||||
|
||||
if !allowedTags[name] {
|
||||
continue // drop the tag entirely, keep surrounding text
|
||||
}
|
||||
@@ -93,6 +118,15 @@ func SanitizeHTML(in string) string {
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// escapeText normalizes a text run from (possibly entity-encoded) input HTML:
|
||||
// decode existing entities, then re-escape. This keeps a single round of
|
||||
// escaping so entities the browser emits — e.g. for spaces around inline
|
||||
// elements, or & for a typed "&" — don't get double-escaped into visible
|
||||
// " "/"&" text. Re-escaping keeps the output XSS-safe.
|
||||
func escapeText(s string) string {
|
||||
return html.EscapeString(html.UnescapeString(s))
|
||||
}
|
||||
|
||||
// splitTag separates the tag name from its raw attribute string.
|
||||
func splitTag(raw string) (string, string) {
|
||||
for i := 0; i < len(raw); i++ {
|
||||
@@ -104,6 +138,50 @@ func splitTag(raw string) (string, string) {
|
||||
return raw, ""
|
||||
}
|
||||
|
||||
// mentionUID validates a mention span's attributes and returns the referenced
|
||||
// user id (alphanumeric, ULID-shaped), or "" if the span is not a valid
|
||||
// mention.
|
||||
func mentionUID(attrs string) string {
|
||||
low := strings.ToLower(attrs)
|
||||
if !strings.Contains(low, "mention") {
|
||||
return ""
|
||||
}
|
||||
idx := strings.Index(low, "data-user-card")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := strings.TrimSpace(attrs[idx+len("data-user-card"):])
|
||||
if !strings.HasPrefix(rest, "=") {
|
||||
return ""
|
||||
}
|
||||
rest = strings.TrimSpace(rest[1:])
|
||||
var val string
|
||||
switch {
|
||||
case strings.HasPrefix(rest, `"`):
|
||||
if e := strings.IndexByte(rest[1:], '"'); e >= 0 {
|
||||
val = rest[1 : 1+e]
|
||||
}
|
||||
case strings.HasPrefix(rest, "'"):
|
||||
if e := strings.IndexByte(rest[1:], '\''); e >= 0 {
|
||||
val = rest[1 : 1+e]
|
||||
}
|
||||
default:
|
||||
val = rest
|
||||
if sp := strings.IndexAny(val, " \t"); sp >= 0 {
|
||||
val = val[:sp]
|
||||
}
|
||||
}
|
||||
if val == "" || len(val) > 32 {
|
||||
return ""
|
||||
}
|
||||
for _, r := range val {
|
||||
if !((r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// safeHref extracts href and accepts only http, https, or mailto schemes.
|
||||
func safeHref(attrs string) string {
|
||||
lower := strings.ToLower(attrs)
|
||||
@@ -150,10 +228,10 @@ func SanitizePlain(in string) string {
|
||||
if in[i] != '<' {
|
||||
j := strings.IndexByte(in[i:], '<')
|
||||
if j < 0 {
|
||||
out.WriteString(html.EscapeString(in[i:]))
|
||||
out.WriteString(escapeText(in[i:]))
|
||||
break
|
||||
}
|
||||
out.WriteString(html.EscapeString(in[i : i+j]))
|
||||
out.WriteString(escapeText(in[i : i+j]))
|
||||
i += j
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ func TestSanitizeHTML(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{"plain text escaped", `hello <world> & "friends"`, "hello & "friends""},
|
||||
{"existing entity not double-escaped", `a & b <ok>`, "a & b <ok>"},
|
||||
{"mention span kept with sanitized uid", `<span class="mention" data-user-card="01ABCdef">@Jane Doe</span> hi`,
|
||||
`<span class="mention" data-user-card="01ABCdef">@Jane Doe</span> hi`},
|
||||
{"non-mention span dropped", `<span style="x">keep text</span>`, "keep text"},
|
||||
{"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>"},
|
||||
|
||||
@@ -54,9 +54,14 @@ type Config struct {
|
||||
AdminEmail string
|
||||
AdminInitialPassword string
|
||||
|
||||
AtomizerBaseURL string
|
||||
AtomizerToken string
|
||||
AtomizerTimeout time.Duration
|
||||
AtomizerBaseURL string
|
||||
AtomizerToken string
|
||||
AtomizerTimeout time.Duration
|
||||
// AtomizerRemoteBaseURL / AtomizerRemoteAPIKey enable the Anypreta Issue
|
||||
// Atomizer as the Subdivide backend. Both must be set; Extend and
|
||||
// Summarize stay on ATOMIZER_BASE_URL either way.
|
||||
AtomizerRemoteBaseURL string
|
||||
AtomizerRemoteAPIKey string
|
||||
WorkPerformerBaseURL string
|
||||
WorkPerformerToken string
|
||||
WorkPerformerHTTPTimeout time.Duration
|
||||
@@ -115,10 +120,12 @@ func Load() (*Config, error) {
|
||||
AdminEmail: strings.ToLower(strings.TrimSpace(getenv("ADMIN_EMAIL", ""))),
|
||||
AdminInitialPassword: os.Getenv("ADMIN_INITIAL_PASSWORD"),
|
||||
|
||||
AtomizerBaseURL: strings.TrimRight(getenv("ATOMIZER_BASE_URL", "http://atomizer-mock:8090"), "/"),
|
||||
AtomizerToken: os.Getenv("ATOMIZER_TOKEN"),
|
||||
WorkPerformerBaseURL: strings.TrimRight(getenv("WORK_PERFORMER_BASE_URL", "http://work-performer:8091"), "/"),
|
||||
WorkPerformerToken: os.Getenv("WORK_PERFORMER_TOKEN"),
|
||||
AtomizerBaseURL: strings.TrimRight(getenv("ATOMIZER_BASE_URL", "http://atomizer-mock:8090"), "/"),
|
||||
AtomizerToken: os.Getenv("ATOMIZER_TOKEN"),
|
||||
AtomizerRemoteBaseURL: strings.TrimRight(os.Getenv("ATOMIZER_REMOTE_BASE_URL"), "/"),
|
||||
AtomizerRemoteAPIKey: os.Getenv("ATOMIZER_REMOTE_API_KEY"),
|
||||
WorkPerformerBaseURL: strings.TrimRight(getenv("WORK_PERFORMER_BASE_URL", "http://work-performer:8091"), "/"),
|
||||
WorkPerformerToken: os.Getenv("WORK_PERFORMER_TOKEN"),
|
||||
|
||||
SMTP: SMTP{
|
||||
Host: os.Getenv("SMTP_HOST"),
|
||||
@@ -155,6 +162,14 @@ func Load() (*Config, error) {
|
||||
fail("%s: %q is not an absolute URL", name, v)
|
||||
}
|
||||
}
|
||||
if c.AtomizerRemoteBaseURL != "" {
|
||||
if u, err := url.Parse(c.AtomizerRemoteBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
|
||||
fail("ATOMIZER_REMOTE_BASE_URL: %q is not an absolute URL", c.AtomizerRemoteBaseURL)
|
||||
}
|
||||
if c.AtomizerRemoteAPIKey == "" {
|
||||
fail("ATOMIZER_REMOTE_API_KEY: required when ATOMIZER_REMOTE_BASE_URL is set")
|
||||
}
|
||||
}
|
||||
if len(c.SessionSecret) < 16 {
|
||||
fail("SESSION_SECRET: must be at least 16 characters of random data")
|
||||
}
|
||||
|
||||
@@ -28,9 +28,14 @@ type Ticketing struct {
|
||||
CredentialsEnc string `bson:"credentialsEnc" json:"-"`
|
||||
ProjectKey string `bson:"projectKey" json:"projectKey"`
|
||||
PollIntervalSec int `bson:"pollIntervalSec" json:"pollIntervalSec"`
|
||||
LastSyncAt time.Time `bson:"lastSyncAt" json:"lastSyncAt"`
|
||||
LastSyncStatus string `bson:"lastSyncStatus" json:"lastSyncStatus"` // "", ok, error
|
||||
LastSyncError string `bson:"lastSyncError" json:"lastSyncError"`
|
||||
// ConsultantIdentities maps a bounty-board consultant id to that
|
||||
// consultant's username in THIS customer's ticketing system. It takes
|
||||
// precedence over the consultant's global extra.ticketingIdentities so an
|
||||
// admin can map accounts per project (§5.3).
|
||||
ConsultantIdentities map[string]string `bson:"consultantIdentities,omitempty" json:"consultantIdentities,omitempty"`
|
||||
LastSyncAt time.Time `bson:"lastSyncAt" json:"lastSyncAt"`
|
||||
LastSyncStatus string `bson:"lastSyncStatus" json:"lastSyncStatus"` // "", ok, error
|
||||
LastSyncError string `bson:"lastSyncError" json:"lastSyncError"`
|
||||
}
|
||||
|
||||
// Customer == "project" in the UI (§4.2).
|
||||
|
||||
@@ -54,6 +54,7 @@ type NotificationPrefs struct {
|
||||
|
||||
type UserSettings struct {
|
||||
Theme string `bson:"theme" json:"theme"`
|
||||
NavLayout string `bson:"navLayout" json:"navLayout"` // "side" (default) | "top"
|
||||
Notifications NotificationPrefs `bson:"notifications" json:"notifications"`
|
||||
LeaderboardOptOut bool `bson:"leaderboardOptOut" json:"leaderboardOptOut"`
|
||||
}
|
||||
|
||||
@@ -121,13 +121,34 @@ func (c *Client) callOnce(ctx context.Context, method, path string, in, out any)
|
||||
if resp.StatusCode >= 400 {
|
||||
se := &ServiceError{Status: resp.StatusCode}
|
||||
var env struct {
|
||||
// §5 envelope: {"error":{"code":…,"message":…}}.
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
// Flat envelope, used by the Anypreta atomizer:
|
||||
// {"error_code":…,"message":…}. FastAPI's own validation
|
||||
// errors carry {"detail":…} instead.
|
||||
ErrorCode string `json:"error_code"`
|
||||
Message string `json:"message"`
|
||||
Detail json.RawMessage `json:"detail"`
|
||||
}
|
||||
if json.Unmarshal(data, &env) == nil {
|
||||
se.Code, se.Message = env.Error.Code, env.Error.Message
|
||||
if se.Code == "" {
|
||||
se.Code = env.ErrorCode
|
||||
}
|
||||
if se.Message == "" {
|
||||
se.Message = env.Message
|
||||
}
|
||||
if se.Message == "" && len(env.Detail) > 0 {
|
||||
se.Message = string(truncate(env.Detail, 200))
|
||||
}
|
||||
}
|
||||
if se.Message == "" {
|
||||
// Never swallow the failure: an unrecognized envelope still
|
||||
// has to reach the logs and the consultant's error toast.
|
||||
se.Message = string(truncate(data, 200))
|
||||
}
|
||||
return se // 4xx is not retryable
|
||||
}
|
||||
|
||||
@@ -102,14 +102,20 @@ func (s *Server) handleAdminPatchSettings(w http.ResponseWriter, r *http.Request
|
||||
|
||||
func (s *Server) handleAdminAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
limit := atoiDefault(q.Get("limit"), 50)
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
entries, err := s.store.ListAudit(r.Context(), q.Get("actorId"), q.Get("entityId"),
|
||||
q.Get("cursor"), atoiDefault(q.Get("limit"), 50))
|
||||
q.Get("cursor"), limit)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "list audit", err)
|
||||
return
|
||||
}
|
||||
// Only advertise a next cursor when the page is full; otherwise this is the
|
||||
// last page and a further fetch would return nothing (the "click twice" bug).
|
||||
next := ""
|
||||
if len(entries) > 0 {
|
||||
if len(entries) == limit {
|
||||
next = entries[len(entries)-1].ID
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"entries": entries, "nextCursor": next})
|
||||
|
||||
@@ -19,16 +19,17 @@ import (
|
||||
// customerPayload is the create/update body. Credentials are write-only:
|
||||
// they are encrypted at rest and never returned.
|
||||
type customerPayload struct {
|
||||
Name *string `json:"name"`
|
||||
Type *domain.TicketingType `json:"type"`
|
||||
BaseURL *string `json:"baseUrl"`
|
||||
ProjectKey *string `json:"projectKey"`
|
||||
Credentials *syncpkg.Credentials `json:"credentials"`
|
||||
PollInterval *int `json:"pollIntervalSec"`
|
||||
ConsultantIDs *[]string `json:"consultantIds"`
|
||||
DefaultBudget *float64 `json:"defaultBudget"`
|
||||
Archived *bool `json:"archived"`
|
||||
Version *int64 `json:"version"`
|
||||
Name *string `json:"name"`
|
||||
Type *domain.TicketingType `json:"type"`
|
||||
BaseURL *string `json:"baseUrl"`
|
||||
ProjectKey *string `json:"projectKey"`
|
||||
Credentials *syncpkg.Credentials `json:"credentials"`
|
||||
PollInterval *int `json:"pollIntervalSec"`
|
||||
ConsultantIDs *[]string `json:"consultantIds"`
|
||||
ConsultantIdentities *map[string]string `json:"consultantIdentities"`
|
||||
DefaultBudget *float64 `json:"defaultBudget"`
|
||||
Archived *bool `json:"archived"`
|
||||
Version *int64 `json:"version"`
|
||||
}
|
||||
|
||||
func (s *Server) sealCredentials(creds syncpkg.Credentials) (string, error) {
|
||||
@@ -63,8 +64,9 @@ func customerJSON(c *domain.Customer) map[string]any {
|
||||
"type": c.Ticketing.Type, "baseUrl": c.Ticketing.BaseURL,
|
||||
"projectKey": c.Ticketing.ProjectKey, "pollIntervalSec": c.Ticketing.PollIntervalSec,
|
||||
"lastSyncAt": c.Ticketing.LastSyncAt, "lastSyncStatus": c.Ticketing.LastSyncStatus,
|
||||
"lastSyncError": c.Ticketing.LastSyncError,
|
||||
"hasCredentials": c.Ticketing.CredentialsEnc != "",
|
||||
"lastSyncError": c.Ticketing.LastSyncError,
|
||||
"hasCredentials": c.Ticketing.CredentialsEnc != "",
|
||||
"consultantIdentities": c.Ticketing.ConsultantIdentities,
|
||||
},
|
||||
"consultantIds": c.ConsultantIDs, "defaultBudget": c.DefaultBudget,
|
||||
"archived": c.Archived, "createdAt": c.CreatedAt, "updatedAt": c.UpdatedAt,
|
||||
@@ -99,6 +101,18 @@ func (s *Server) handleAdminGetCustomer(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"customer": customerJSON(c)})
|
||||
}
|
||||
|
||||
// cleanIdentities trims values and drops empty mappings so we don't persist
|
||||
// blank usernames for consultants the admin left unmapped.
|
||||
func cleanIdentities(in map[string]string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for k, v := range in {
|
||||
if t := strings.TrimSpace(v); t != "" {
|
||||
out[k] = t
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) validateConsultants(ctx context.Context, ids []string) (string, bool) {
|
||||
for _, id := range ids {
|
||||
u, err := s.store.UserByID(ctx, id)
|
||||
@@ -156,6 +170,9 @@ func (s *Server) handleAdminCreateCustomer(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
c.ConsultantIDs = *req.ConsultantIDs
|
||||
}
|
||||
if req.ConsultantIdentities != nil {
|
||||
c.Ticketing.ConsultantIdentities = cleanIdentities(*req.ConsultantIdentities)
|
||||
}
|
||||
creds := syncpkg.Credentials{}
|
||||
if req.Credentials != nil {
|
||||
creds = *req.Credentials
|
||||
@@ -236,6 +253,10 @@ func (s *Server) handleAdminPatchCustomer(w http.ResponseWriter, r *http.Request
|
||||
set["consultantIds"] = *req.ConsultantIDs
|
||||
diff["consultantIds"] = *req.ConsultantIDs
|
||||
}
|
||||
if req.ConsultantIdentities != nil {
|
||||
set["ticketing.consultantIdentities"] = cleanIdentities(*req.ConsultantIdentities)
|
||||
diff["consultantIdentities"] = "updated"
|
||||
}
|
||||
if req.DefaultBudget != nil {
|
||||
set["defaultBudget"] = *req.DefaultBudget
|
||||
diff["defaultBudget"] = *req.DefaultBudget
|
||||
|
||||
+50
-35
@@ -19,7 +19,9 @@ import (
|
||||
|
||||
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))))
|
||||
// Developers and consultants can both read the board (consultants browse to
|
||||
// open task detail; claim/start/etc. below stay developer-only).
|
||||
mux.Handle("GET /api/v1/board", s.requireAuth(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))
|
||||
@@ -39,17 +41,10 @@ func (s *Server) routesBoard(mux *http.ServeMux) {
|
||||
// assigned to a customer manage its tasks, so a task published by any of
|
||||
// them belongs to the same board.
|
||||
func (s *Server) boardCustomers(r *http.Request) ([]domain.Customer, error) {
|
||||
consultants, err := s.store.PoolConsultantIDs(r.Context(), CurrentUser(r.Context()).ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
me := CurrentUser(r.Context())
|
||||
seen := map[string]bool{}
|
||||
out := []domain.Customer{}
|
||||
for _, cid := range consultants {
|
||||
customers, err := s.store.ListCustomers(r.Context(), cid, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
add := func(customers []domain.Customer) {
|
||||
for _, c := range customers {
|
||||
if !seen[c.ID] {
|
||||
seen[c.ID] = true
|
||||
@@ -57,6 +52,28 @@ func (s *Server) boardCustomers(r *http.Request) ([]domain.Customer, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Developers: every customer that has a consultant who pooled them.
|
||||
if me.Roles.Developer {
|
||||
consultants, err := s.store.PoolConsultantIDs(r.Context(), me.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, cid := range consultants {
|
||||
customers, err := s.store.ListCustomers(r.Context(), cid, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
add(customers)
|
||||
}
|
||||
}
|
||||
// Consultants: every customer they are assigned to.
|
||||
if me.Roles.Consultant {
|
||||
customers, err := s.store.ListCustomers(r.Context(), me.ID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
add(customers)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -319,7 +336,9 @@ func (s *Server) handleAbandonTask(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
var mentionRe = regexp.MustCompile(`@([\w.+-]+@[\w.-]+\.\w+|\w+)`)
|
||||
// mentionUIDRe pulls user ids out of mention spans the sanitizer emitted:
|
||||
// <span class="mention" data-user-card="ULID">@Name</span>.
|
||||
var mentionUIDRe = regexp.MustCompile(`data-user-card="([0-9A-Za-z]+)"`)
|
||||
|
||||
func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
@@ -375,43 +394,39 @@ func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"comment": comment})
|
||||
}
|
||||
|
||||
// notifyMentions resolves @name / @email tokens against task participants
|
||||
// (§11.3).
|
||||
// notifyMentions notifies users referenced by mention spans in the comment
|
||||
// body, but only those who actually have access to the task (§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)
|
||||
matches := mentionUIDRe.FindAllStringSubmatch(body, 20)
|
||||
if len(matches) == 0 {
|
||||
return
|
||||
}
|
||||
// candidate participants
|
||||
ids := map[string]bool{t.ConsultantID: true}
|
||||
ctx := r.Context()
|
||||
// who can see this task: its consultants, assignee, claimers, commenters
|
||||
access := map[string]bool{t.ConsultantID: true}
|
||||
if c, err := s.store.CustomerByID(ctx, t.CustomerID); err == nil {
|
||||
for _, id := range c.ConsultantIDs {
|
||||
access[id] = true
|
||||
}
|
||||
}
|
||||
if t.Assignee != nil && t.Assignee.UserID != "" {
|
||||
ids[t.Assignee.UserID] = true
|
||||
access[t.Assignee.UserID] = true
|
||||
}
|
||||
for _, cr := range t.ClaimRequests {
|
||||
ids[cr.DeveloperID] = true
|
||||
access[cr.DeveloperID] = true
|
||||
}
|
||||
for _, c := range t.Comments {
|
||||
ids[c.AuthorID] = true
|
||||
access[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)
|
||||
}
|
||||
uid := m[1]
|
||||
if uid == author.ID || notified[uid] || !access[uid] {
|
||||
continue
|
||||
}
|
||||
notified[uid] = true
|
||||
s.notifyUser(ctx, uid, "mention",
|
||||
author.Name+" mentioned you", "On task: "+t.Title, "/tasks/"+t.ID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+215
-9
@@ -24,8 +24,12 @@ func (s *Server) routesMessages(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/conversations/{id}/messages", s.requireAuth(http.HandlerFunc(s.handleListMessages)))
|
||||
mux.Handle("POST /api/v1/conversations/{id}/messages", s.authed(s.handleSendMessage))
|
||||
mux.Handle("POST /api/v1/conversations/{id}/read", s.authed(s.handleMarkRead))
|
||||
mux.Handle("GET /api/v1/conversations/{id}/members", s.requireAuth(http.HandlerFunc(s.handleListMembers)))
|
||||
mux.Handle("POST /api/v1/conversations/{id}/members", s.authed(s.handleAddMember))
|
||||
mux.Handle("DELETE /api/v1/conversations/{id}/members/{userId}", s.authed(s.handleRemoveMember))
|
||||
mux.Handle("POST /api/v1/files", s.authed(s.handleUploadFile))
|
||||
mux.Handle("GET /api/v1/users", s.requireAuth(http.HandlerFunc(s.handleSearchUsers)))
|
||||
mux.Handle("GET /api/v1/messages/search", s.requireAuth(http.HandlerFunc(s.handleSearchMessages)))
|
||||
}
|
||||
|
||||
// conversation loads + authorizes participant access.
|
||||
@@ -81,6 +85,7 @@ func (s *Server) handleListConversations(w http.ResponseWriter, r *http.Request)
|
||||
out[i] = map[string]any{
|
||||
"id": c.ID, "kind": c.Kind, "title": title,
|
||||
"customerId": c.CustomerID, "participantIds": c.ParticipantIDs,
|
||||
"creatorId": c.CreatorID,
|
||||
"lastMessageAt": c.LastMessageAt, "unread": unread[c.ID],
|
||||
}
|
||||
}
|
||||
@@ -135,6 +140,7 @@ func (s *Server) handleCreateConversation(w http.ResponseWriter, r *http.Request
|
||||
conv := &store.Conversation{
|
||||
Kind: req.Kind,
|
||||
Title: strings.TrimSpace(req.Title),
|
||||
CreatorID: me.ID,
|
||||
ParticipantIDs: participants,
|
||||
}
|
||||
if req.Kind == "project" {
|
||||
@@ -226,19 +232,21 @@ func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
if s.sendTo != nil {
|
||||
s.sendTo(conv.ParticipantIDs, "chat", "message", msg)
|
||||
}
|
||||
// Notify mentioned users who are participants (i.e. have access §11.1).
|
||||
plain := chat.SanitizePlain(body)
|
||||
participants := map[string]bool{}
|
||||
for _, pid := range conv.ParticipantIDs {
|
||||
if pid == me.ID {
|
||||
participants[pid] = true
|
||||
}
|
||||
notified := map[string]bool{}
|
||||
for _, m := range mentionUIDRe.FindAllStringSubmatch(body, 20) {
|
||||
uid := m[1]
|
||||
if uid == me.ID || notified[uid] || !participants[uid] {
|
||||
continue
|
||||
}
|
||||
if u, err := s.store.UserByID(r.Context(), pid); err == nil {
|
||||
first := strings.ToLower(strings.SplitN(u.Name, " ", 2)[0])
|
||||
lower := strings.ToLower(plain)
|
||||
if strings.Contains(lower, "@"+strings.ToLower(u.Email)) || strings.Contains(lower, "@"+first) {
|
||||
s.notifyUser(r.Context(), pid, "mention",
|
||||
me.Name+" mentioned you in a chat", truncateStr(plain, 120), "/messages?c="+conv.ID)
|
||||
}
|
||||
}
|
||||
notified[uid] = true
|
||||
s.notifyUser(r.Context(), uid, "mention",
|
||||
me.Name+" mentioned you in a chat", truncateStr(plain, 120), "/messages?c="+conv.ID)
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"message": msg})
|
||||
}
|
||||
@@ -256,6 +264,93 @@ func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"marked": n})
|
||||
}
|
||||
|
||||
// groupOwner loads a group/project conversation and authorizes the creator.
|
||||
func (s *Server) groupOwner(w http.ResponseWriter, r *http.Request, id string) (*store.Conversation, bool) {
|
||||
conv, ok := s.conversation(w, r, id)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if conv.Kind == "dm" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "direct messages have no managed members")
|
||||
return nil, false
|
||||
}
|
||||
if conv.CreatorID != CurrentUser(r.Context()).ID {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "only the group creator can manage members")
|
||||
return nil, false
|
||||
}
|
||||
return conv, true
|
||||
}
|
||||
|
||||
// handleListMembers returns the participants; any member may view.
|
||||
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
conv, ok := s.conversation(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
me := CurrentUser(r.Context())
|
||||
out := make([]map[string]any, 0, len(conv.ParticipantIDs))
|
||||
for _, id := range conv.ParticipantIDs {
|
||||
u, err := s.store.UserByID(r.Context(), id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": u.ID, "name": u.Name, "email": u.Email,
|
||||
"avatarFileId": u.AvatarFileID, "isCreator": id == conv.CreatorID,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"members": out,
|
||||
"creatorId": conv.CreatorID,
|
||||
"canManage": conv.Kind != "dm" && conv.CreatorID == me.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// handleAddMember adds a participant to a group (creator only).
|
||||
func (s *Server) handleAddMember(w http.ResponseWriter, r *http.Request) {
|
||||
conv, ok := s.groupOwner(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.UserID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "userId is required")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.UserByID(r.Context(), req.UserID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_user", "unknown user")
|
||||
return
|
||||
}
|
||||
if err := s.store.AddParticipant(r.Context(), conv.ID, req.UserID); err != nil {
|
||||
s.internalError(w, r, "add member", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleRemoveMember removes a participant from a group (creator only).
|
||||
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
conv, ok := s.groupOwner(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
target := r.PathValue("userId")
|
||||
if target == conv.CreatorID {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "the creator cannot be removed")
|
||||
return
|
||||
}
|
||||
if err := s.store.RemoveParticipant(r.Context(), conv.ID, target); err != nil {
|
||||
s.internalError(w, r, "remove member", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleUploadFile is the generic upload endpoint (POST /files, §6) used by
|
||||
// chat; scope=chat unless the caller passes scope=task (consultants).
|
||||
func (s *Server) handleUploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -322,6 +417,117 @@ func (s *Server) handleSearchUsers(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": out})
|
||||
}
|
||||
|
||||
// handleSearchMessages finds messages containing the query within the caller's
|
||||
// own conversations, newest first.
|
||||
func (s *Server) handleSearchMessages(w http.ResponseWriter, r *http.Request) {
|
||||
me := CurrentUser(r.Context())
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if len([]rune(q)) < 2 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"results": []any{}})
|
||||
return
|
||||
}
|
||||
convs, err := s.store.ListConversations(r.Context(), me.ID)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "search: conversations", err)
|
||||
return
|
||||
}
|
||||
if len(convs) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"results": []any{}})
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(convs))
|
||||
titles := make(map[string]string, len(convs))
|
||||
for _, c := range convs {
|
||||
ids = append(ids, c.ID)
|
||||
title := c.Title
|
||||
if c.Kind == "dm" {
|
||||
for _, pid := range c.ParticipantIDs {
|
||||
if pid != me.ID {
|
||||
if u, err := s.store.UserByID(r.Context(), pid); err == nil {
|
||||
title = u.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if title == "" {
|
||||
title = "Conversation"
|
||||
}
|
||||
titles[c.ID] = title
|
||||
}
|
||||
cur, err := s.store.DB.Collection("messages").Find(r.Context(), bson.M{
|
||||
"conversationId": bson.M{"$in": ids},
|
||||
"deletedAt": nil,
|
||||
"body": bson.M{"$regex": regexEscape(q), "$options": "i"},
|
||||
}, options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(30))
|
||||
if err != nil {
|
||||
s.internalError(w, r, "search messages", err)
|
||||
return
|
||||
}
|
||||
var msgs []store.Message
|
||||
if err := cur.All(r.Context(), &msgs); err != nil {
|
||||
s.internalError(w, r, "decode search", err)
|
||||
return
|
||||
}
|
||||
results := make([]map[string]any, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
results = append(results, map[string]any{
|
||||
"conversationId": m.ConversationID,
|
||||
"conversationTitle": titles[m.ConversationID],
|
||||
"messageId": m.ID,
|
||||
"senderId": m.SenderID,
|
||||
"snippet": snippetAround(stripTags(m.Body), q),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"results": results})
|
||||
}
|
||||
|
||||
// stripTags removes HTML tags and collapses whitespace for plain-text snippets.
|
||||
func stripTags(html string) string {
|
||||
var b strings.Builder
|
||||
inTag := false
|
||||
for _, r := range html {
|
||||
switch {
|
||||
case r == '<':
|
||||
inTag = true
|
||||
case r == '>':
|
||||
inTag = false
|
||||
case !inTag:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.Join(strings.Fields(b.String()), " ")
|
||||
}
|
||||
|
||||
// snippetAround returns a short text window centered on the first
|
||||
// case-insensitive match of q.
|
||||
func snippetAround(text, q string) string {
|
||||
runes := []rune(text)
|
||||
idx := strings.Index(strings.ToLower(text), strings.ToLower(q))
|
||||
if idx < 0 {
|
||||
if len(runes) > 100 {
|
||||
return string(runes[:100]) + "…"
|
||||
}
|
||||
return text
|
||||
}
|
||||
start := len([]rune(text[:idx]))
|
||||
from := start - 30
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
to := start + len([]rune(q)) + 50
|
||||
if to > len(runes) {
|
||||
to = len(runes)
|
||||
}
|
||||
out := string(runes[from:to])
|
||||
if from > 0 {
|
||||
out = "…" + out
|
||||
}
|
||||
if to < len(runes) {
|
||||
out += "…"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HandleInboundWS relays ephemeral client events (typing indicator).
|
||||
func (s *Server) HandleInboundWS(userID string, msg ws.Message) {
|
||||
if msg.Channel != "chat" || msg.Event != "typing" {
|
||||
|
||||
@@ -15,6 +15,12 @@ import (
|
||||
"bountyboard/internal/store"
|
||||
)
|
||||
|
||||
// validThemes are the selectable UI themes (must match web/static/js/theme.js).
|
||||
var validThemes = map[string]bool{
|
||||
"light": true, "dark": true, "neon": true,
|
||||
"terminal": true, "blueprint": true, "sunset": true,
|
||||
}
|
||||
|
||||
func (s *Server) routesProfile(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/profile", s.requireAuth(http.HandlerFunc(s.handleGetProfile)))
|
||||
mux.Handle("PATCH /api/v1/profile", s.authed(s.handlePatchProfile))
|
||||
@@ -39,6 +45,7 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
|
||||
Extra *map[string]any `json:"extra"`
|
||||
Settings *struct {
|
||||
Theme *string `json:"theme"`
|
||||
NavLayout *string `json:"navLayout"`
|
||||
Notifications *domain.NotificationPrefs `json:"notifications"`
|
||||
LeaderboardOptOut *bool `json:"leaderboardOptOut"`
|
||||
} `json:"settings"`
|
||||
@@ -71,12 +78,20 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Settings != nil {
|
||||
if req.Settings.Theme != nil {
|
||||
theme := *req.Settings.Theme
|
||||
if theme != "light" && theme != "dark" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_theme", "theme must be light or dark")
|
||||
if !validThemes[theme] {
|
||||
writeError(w, http.StatusBadRequest, "invalid_theme", "unknown theme")
|
||||
return
|
||||
}
|
||||
set["settings.theme"] = theme
|
||||
}
|
||||
if req.Settings.NavLayout != nil {
|
||||
nav := *req.Settings.NavLayout
|
||||
if nav != "side" && nav != "top" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_nav", "navLayout must be side or top")
|
||||
return
|
||||
}
|
||||
set["settings.navLayout"] = nav
|
||||
}
|
||||
if req.Settings.Notifications != nil {
|
||||
set["settings.notifications"] = *req.Settings.Notifications
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ func TestProfilePatchAndThemePersistence(t *testing.T) {
|
||||
|
||||
// invalid theme rejected
|
||||
resp = patchJSON(t, c, ts.URL+"/api/v1/profile", map[string]any{
|
||||
"settings": map[string]any{"theme": "neon"},
|
||||
"settings": map[string]any{"theme": "rainbow-unicorn"},
|
||||
}, csrf)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("invalid theme: %d", resp.StatusCode)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"bountyboard/internal/atomize"
|
||||
"bountyboard/internal/domain"
|
||||
@@ -23,10 +24,66 @@ func (s *Server) routesTasks(mux *http.ServeMux) {
|
||||
mux.Handle("POST /api/v1/tasks/publish", s.authedRole("consultant", s.handlePublishBulk))
|
||||
mux.Handle("POST /api/v1/tasks/{id}/archive", s.authed(s.handleArchiveTask))
|
||||
mux.Handle("POST /api/v1/tasks/archive", s.authedRole("consultant", s.handleArchiveBulk))
|
||||
mux.Handle("GET /api/v1/archive", s.requireAuth(http.HandlerFunc(s.handleArchive)))
|
||||
mux.Handle("GET /api/v1/tasks/{id}", s.requireAuth(http.HandlerFunc(s.handleTaskDetail)))
|
||||
mux.Handle("GET /api/v1/service-health", s.requireAuth(http.HandlerFunc(s.handleServiceHealth)))
|
||||
}
|
||||
|
||||
// handleArchive lists archived tasks scoped by role: admins see everything,
|
||||
// consultants see their customers' tasks, developers see tasks they were
|
||||
// assigned to or worked on. Optional ?q= full-text search.
|
||||
func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
|
||||
me := CurrentUser(r.Context())
|
||||
q := bson.M{"status": domain.StatusArchived}
|
||||
if !me.Roles.Admin {
|
||||
ors := []bson.M{}
|
||||
if me.Roles.Consultant {
|
||||
cs, err := s.store.ListCustomers(r.Context(), me.ID, true)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "archive customers", err)
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(cs))
|
||||
for _, c := range cs {
|
||||
ids = append(ids, c.ID)
|
||||
}
|
||||
ors = append(ors, bson.M{"customerId": bson.M{"$in": ids}})
|
||||
}
|
||||
if me.Roles.Developer {
|
||||
ors = append(ors,
|
||||
bson.M{"assignee.userId": me.ID},
|
||||
bson.M{"claimRequests.developerId": me.ID})
|
||||
}
|
||||
// everyone also sees archived tasks they personally acted on
|
||||
ors = append(ors, bson.M{"timeline.actorId": me.ID})
|
||||
q["$or"] = ors
|
||||
}
|
||||
if search := strings.TrimSpace(r.URL.Query().Get("q")); search != "" {
|
||||
q["$text"] = bson.M{"$search": search}
|
||||
}
|
||||
cur, err := s.store.DB.Collection("tasks").Find(r.Context(), q,
|
||||
options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(200))
|
||||
if err != nil {
|
||||
s.internalError(w, r, "list archive", err)
|
||||
return
|
||||
}
|
||||
tasks := []domain.Task{}
|
||||
if err := cur.All(r.Context(), &tasks); err != nil {
|
||||
s.internalError(w, r, "decode archive", err)
|
||||
return
|
||||
}
|
||||
// resolve customer names for display
|
||||
names := map[string]string{}
|
||||
for _, t := range tasks {
|
||||
if t.CustomerID != "" && names[t.CustomerID] == "" {
|
||||
if c, err := s.store.CustomerByID(r.Context(), t.CustomerID); err == nil {
|
||||
names[t.CustomerID] = c.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customerNames": names})
|
||||
}
|
||||
|
||||
// consultantTask loads a task and authorizes the current user: admins and
|
||||
// every consultant assigned to the task's customer may manage it (§4.4).
|
||||
func (s *Server) consultantTask(w http.ResponseWriter, r *http.Request, id string) (*domain.Task, *domain.Customer, bool) {
|
||||
|
||||
+27
-1
@@ -22,6 +22,8 @@ type pageData struct {
|
||||
Active string // nav highlight key
|
||||
User *domain.User
|
||||
DefaultTheme string
|
||||
NavLayout string
|
||||
Brand string
|
||||
Narrow bool
|
||||
OIDCEnabled bool
|
||||
Error string
|
||||
@@ -81,6 +83,18 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, dat
|
||||
data.DefaultTheme = data.User.Settings.Theme
|
||||
}
|
||||
}
|
||||
if data.NavLayout == "" {
|
||||
data.NavLayout = "side" // sidebar by default
|
||||
if data.User != nil && data.User.Settings.NavLayout == "top" {
|
||||
data.NavLayout = "top"
|
||||
}
|
||||
}
|
||||
if data.Brand == "" {
|
||||
data.Brand = "Bounty Board"
|
||||
if st, err := s.store.GetSettings(r.Context()); err == nil && st.BrandingName != "" {
|
||||
data.Brand = st.BrandingName
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
s.log.Error("execute template", "page", page, "err", err)
|
||||
@@ -231,11 +245,15 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
|
||||
}
|
||||
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)
|
||||
isDevOrCons := func(u *domain.User) bool { return u.Roles.Developer || u.Roles.Consultant }
|
||||
// Consultants can browse the board too (read-only: they open task detail to
|
||||
// review comments/assignments); only developers see claim actions.
|
||||
rolePage("/board", "board.html", "Bounty Board", "board", "/static/js/board.js", isDevOrCons)
|
||||
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)
|
||||
rolePage("/archive", "archive.html", "Archive", "archive", "/static/js/archive.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{
|
||||
@@ -245,6 +263,14 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
|
||||
})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /users/{id}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
|
||||
s.render(w, r, "public-profile.html", &pageData{
|
||||
Title: "Profile", User: u,
|
||||
Scripts: []string{"/static/js/public-profile.js"},
|
||||
Data: map[string]any{"UserID": r.PathValue("id")},
|
||||
})
|
||||
}))
|
||||
|
||||
rolePage("/messages", "messages.html", "Messages", "messages", "/static/js/messages.js", nil)
|
||||
rolePage("/metrics", "metrics.html", "Metrics", "metrics", "/static/js/metrics.js", nil)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type Conversation struct {
|
||||
Kind string `bson:"kind" json:"kind"` // dm | group | project
|
||||
CustomerID string `bson:"customerId,omitempty" json:"customerId,omitempty"`
|
||||
Title string `bson:"title,omitempty" json:"title,omitempty"`
|
||||
CreatorID string `bson:"creatorId,omitempty" json:"creatorId,omitempty"`
|
||||
ParticipantIDs []string `bson:"participantIds" json:"participantIds"`
|
||||
LastMessageAt time.Time `bson:"lastMessageAt" json:"lastMessageAt"`
|
||||
CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
|
||||
@@ -104,6 +105,28 @@ func (s *Store) ConversationByID(ctx context.Context, id string) (*Conversation,
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// AddParticipant adds a user to a conversation (idempotent via $addToSet).
|
||||
func (s *Store) AddParticipant(ctx context.Context, convID, userID string) error {
|
||||
_, err := s.DB.Collection("conversations").UpdateOne(ctx,
|
||||
bson.M{"_id": convID},
|
||||
bson.M{"$addToSet": bson.M{"participantIds": userID}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("add participant: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveParticipant removes a user from a conversation.
|
||||
func (s *Store) RemoveParticipant(ctx context.Context, convID, userID string) error {
|
||||
_, err := s.DB.Collection("conversations").UpdateOne(ctx,
|
||||
bson.M{"_id": convID},
|
||||
bson.M{"$pull": bson.M{"participantIds": userID}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove participant: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListConversations(ctx context.Context, userID string) ([]Conversation, error) {
|
||||
cur, err := s.DB.Collection("conversations").Find(ctx,
|
||||
bson.M{"participantIds": userID},
|
||||
|
||||
+28
-10
@@ -2,9 +2,9 @@ package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -47,23 +47,41 @@ type jiraIssue struct {
|
||||
} `json:"fields"`
|
||||
}
|
||||
|
||||
// search runs a JQL query via POST /rest/api/3/search/jql — the replacement
|
||||
// for the removed GET /rest/api/3/search endpoint (Atlassian CHANGE-2046).
|
||||
// It uses cursor pagination (nextPageToken/isLast); the legacy startAt/total
|
||||
// fields no longer exist.
|
||||
func (j *jiraConnector) search(ctx context.Context, jql, fields string) ([]jiraIssue, error) {
|
||||
fieldList := strings.Split(fields, ",")
|
||||
u := j.baseURL + "/rest/api/3/search/jql"
|
||||
var all []jiraIssue
|
||||
for startAt := 0; ; {
|
||||
var page struct {
|
||||
Issues []jiraIssue `json:"issues"`
|
||||
Total int `json:"total"`
|
||||
nextPageToken := ""
|
||||
for {
|
||||
reqBody := map[string]any{
|
||||
"jql": jql,
|
||||
"fields": fieldList,
|
||||
"maxResults": 100,
|
||||
}
|
||||
u := fmt.Sprintf("%s/rest/api/3/search?jql=%s&fields=%s&maxResults=100&startAt=%d",
|
||||
j.baseURL, url.QueryEscape(jql), url.QueryEscape(fields), startAt)
|
||||
if err := getJSON(ctx, j.Authorize, u, &page); err != nil {
|
||||
if nextPageToken != "" {
|
||||
reqBody["nextPageToken"] = nextPageToken
|
||||
}
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jira search: encode request: %w", err)
|
||||
}
|
||||
var page struct {
|
||||
Issues []jiraIssue `json:"issues"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
IsLast bool `json:"isLast"`
|
||||
}
|
||||
if err := doJSON(ctx, j.Authorize, http.MethodPost, u, body, &page); err != nil {
|
||||
return nil, fmt.Errorf("jira search: %w", err)
|
||||
}
|
||||
all = append(all, page.Issues...)
|
||||
startAt += len(page.Issues)
|
||||
if len(page.Issues) == 0 || startAt >= page.Total {
|
||||
if page.IsLast || page.NextPageToken == "" || len(page.Issues) == 0 {
|
||||
return all, nil
|
||||
}
|
||||
nextPageToken = page.NextPageToken
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -257,7 +257,12 @@ func (m *Manager) SyncCustomer(ctx context.Context, c *domain.Customer) error {
|
||||
if err != nil {
|
||||
continue // consultant deleted; reconcile will catch up
|
||||
}
|
||||
identity := ticketingIdentity(consultant, c.Ticketing.Type)
|
||||
// Per-customer mapping (set by the admin on the customer) wins over
|
||||
// the consultant's global identity; fall back to the global one.
|
||||
identity := c.Ticketing.ConsultantIdentities[consultantID]
|
||||
if identity == "" {
|
||||
identity = ticketingIdentity(consultant, c.Ticketing.Type)
|
||||
}
|
||||
if identity == "" {
|
||||
continue // §5.3: consultant has no linked account in this system
|
||||
}
|
||||
|
||||
+1
-1
@@ -441,7 +441,7 @@ account** in that system, updated since `lastSyncAt − 5 min` (overlap window):
|
||||
|
||||
| System | Query mechanism |
|
||||
|---|---|
|
||||
| Jira Cloud | `GET /rest/api/3/search?jql=assignee="<email>" AND project=<key> AND updated >= "<ts>"` (Basic auth: email+API token) |
|
||||
| Jira Cloud | `POST /rest/api/3/search/jql` with body `{jql:'assignee="<email>" AND project=<key> AND updated >= "<ts>"', fields, maxResults}`, cursor-paginated via `nextPageToken`/`isLast` (Basic auth: email+API token). The legacy `GET /rest/api/3/search` was removed by Atlassian — CHANGE-2046. |
|
||||
| Azure DevOps | `POST …/_apis/wit/wiql?api-version=7.1` with WIQL `[System.AssignedTo] = '<email>' AND [System.TeamProject] = '<project>' AND [System.ChangedDate] >= '<ts>'`, then batch `GET workitems` (PAT auth) |
|
||||
| YouTrack | `GET /api/issues?query=assignee: <login> project: <key> updated: <ts> .. *` (Bearer permanent token) |
|
||||
|
||||
|
||||
+257
-8
@@ -56,8 +56,116 @@
|
||||
:root[data-theme=dark] {
|
||||
--ink-shadow: 3px 3px 0 rgba(0, 0, 0, 0.55);
|
||||
--ink-shadow-lift: 5px 5px 0 rgba(0, 0, 0, 0.65);
|
||||
--dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' x='0' y='0' fill='%23d9a548'/%3E%3Crect width='1' height='1' x='2' y='2' fill='%23d9a548'/%3E%3C/svg%3E");
|
||||
--dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2' height='2'%3E%3Crect width='1' height='1' x='0' y='0' fill='%23d9a548'/%3E%3C/svg%3E");
|
||||
--dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Crect width='1' height='1' x='0' y='0' fill='%233a2c12'/%3E%3C/svg%3E");
|
||||
--dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' x='0' y='0' fill='%233a2c12'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* ============================================================= */
|
||||
/* Additional dramatic themes (user-requested). Each redefines the
|
||||
full token set + an atmospheric override for a distinct look. */
|
||||
/* ============================================================= */
|
||||
|
||||
/* ---- NEON — cyber, magenta/cyan gradients, glowing accents ---- */
|
||||
:root[data-theme=neon] {
|
||||
--bg:#080014; --surface:#160a32; --surface2:#221248; --border:#7a3bf0;
|
||||
--text:#ece6ff; --muted:#a48fe0; --accent:#00e5ff; --accent-contrast:#06000f;
|
||||
--ok:#39ff9e; --warn:#ffd23f; --err:#ff3b8b; --radius:8px;
|
||||
--ink-shadow: 0 0 14px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
--ink-shadow-lift: 0 0 26px color-mix(in srgb, var(--accent) 70%, transparent);
|
||||
--dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Crect width='1' height='1' x='0' y='0' fill='%23221046'/%3E%3C/svg%3E");
|
||||
--dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' x='0' y='0' fill='%23221046'/%3E%3C/svg%3E");
|
||||
}
|
||||
:root[data-theme=neon] body {
|
||||
background:
|
||||
radial-gradient(900px 500px at 12% -8%, rgba(255,59,214,0.28), transparent 60%),
|
||||
radial-gradient(900px 500px at 92% 4%, rgba(0,229,255,0.24), transparent 60%),
|
||||
linear-gradient(170deg, #0c0020 0%, #080014 55%, #04000c 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
:root[data-theme=neon] .card,
|
||||
:root[data-theme=neon] .kanban-col {
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 18%, transparent),
|
||||
0 0 22px rgba(122,59,240,0.18);
|
||||
}
|
||||
:root[data-theme=neon] .btn.primary {
|
||||
background: linear-gradient(120deg, #00e5ff, #b34dff);
|
||||
border: none; color: #06000f;
|
||||
box-shadow: 0 0 16px rgba(0,229,255,0.5);
|
||||
}
|
||||
:root[data-theme=neon] h1, :root[data-theme=neon] .brand {
|
||||
text-shadow: 0 0 18px rgba(0,229,255,0.45);
|
||||
}
|
||||
|
||||
/* ---- TERMINAL — green phosphor CRT, mono everything, scanlines ---- */
|
||||
:root[data-theme=terminal] {
|
||||
--bg:#000a02; --surface:#04140a; --surface2:#06200f; --border:#1f8f3f;
|
||||
--text:#41ff7a; --muted:#1fb04f; --accent:#7dff9f; --accent-contrast:#021006;
|
||||
--ok:#7dff9f; --warn:#d7ff3f; --err:#ff5d5d; --radius:0px;
|
||||
--ink-shadow: 0 0 10px rgba(65,255,122,0.4);
|
||||
--ink-shadow-lift: 0 0 18px rgba(65,255,122,0.55);
|
||||
/* interlaced CRT scanlines (horizontal lines, not dots) */
|
||||
--dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='3'%3E%3Crect width='1' height='1' x='0' y='0' fill='%230a3315'/%3E%3C/svg%3E");
|
||||
--dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='2'%3E%3Crect width='1' height='1' x='0' y='0' fill='%230a3315'/%3E%3C/svg%3E");
|
||||
--font-display: "Spline Sans Mono", ui-monospace, monospace;
|
||||
--font-body: "Spline Sans Mono", ui-monospace, monospace;
|
||||
}
|
||||
:root[data-theme=terminal] body {
|
||||
background:
|
||||
repeating-linear-gradient(0deg, rgba(0,0,0,0) 0 2px, rgba(0,40,15,0.55) 2px 3px),
|
||||
radial-gradient(120% 90% at 50% 0%, #03240f 0%, #000a02 70%);
|
||||
background-attachment: fixed;
|
||||
text-shadow: 0 0 6px rgba(65,255,122,0.35);
|
||||
}
|
||||
:root[data-theme=terminal] h1 { text-transform: uppercase; letter-spacing: 0.1em; }
|
||||
|
||||
/* ---- BLUEPRINT — technical drawing, graph paper, cyan ink ---- */
|
||||
:root[data-theme=blueprint] {
|
||||
--bg:#082a4d; --surface:#0c3460; --surface2:#114576; --border:#6fb8ff;
|
||||
--text:#eaf4ff; --muted:#9cc6f0; --accent:#ffd24a; --accent-contrast:#082a4d;
|
||||
--ok:#7fffd4; --warn:#ffd24a; --err:#ff9a8a; --radius:0px;
|
||||
--ink-shadow: 4px 4px 0 rgba(0,0,0,0.3);
|
||||
--ink-shadow-lift: 6px 6px 0 rgba(0,0,0,0.4);
|
||||
--dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Crect width='1' height='1' x='0' y='0' fill='%230e3c68'/%3E%3C/svg%3E");
|
||||
--dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' x='0' y='0' fill='%230e3c68'/%3E%3C/svg%3E");
|
||||
}
|
||||
:root[data-theme=blueprint] body {
|
||||
background:
|
||||
repeating-linear-gradient(0deg, transparent 0 23px, rgba(111,184,255,0.16) 23px 24px),
|
||||
repeating-linear-gradient(90deg, transparent 0 23px, rgba(111,184,255,0.16) 23px 24px),
|
||||
#082a4d;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
:root[data-theme=blueprint] .card,
|
||||
:root[data-theme=blueprint] .kanban-col {
|
||||
background: rgba(12,52,96,0.82);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ---- SUNSET — warm vaporwave gradient, soft pastel ink ---- */
|
||||
:root[data-theme=sunset] {
|
||||
--bg:#2a1138; --surface:#3a1a4e; --surface2:#4d2363; --border:#ff8ec7;
|
||||
--text:#fff0fb; --muted:#e0a9d6; --accent:#ffb347; --accent-contrast:#2a1138;
|
||||
--ok:#7ef0c4; --warn:#ffd76a; --err:#ff6b8e; --radius:12px;
|
||||
--ink-shadow: 0 8px 24px rgba(255,107,142,0.25);
|
||||
--ink-shadow-lift: 0 12px 34px rgba(255,142,199,0.35);
|
||||
--dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Crect width='1' height='1' x='0' y='0' fill='%233a1a4e'/%3E%3C/svg%3E");
|
||||
--dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' x='0' y='0' fill='%233a1a4e'/%3E%3C/svg%3E");
|
||||
}
|
||||
:root[data-theme=sunset] body {
|
||||
background:
|
||||
linear-gradient(180deg, #ff6b8e 0%, #b14d9c 26%, #5e2a72 55%, #2a1138 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
:root[data-theme=sunset] .card,
|
||||
:root[data-theme=sunset] .kanban-col {
|
||||
background: rgba(58,26,78,0.7);
|
||||
backdrop-filter: blur(6px);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
|
||||
}
|
||||
:root[data-theme=sunset] .btn.primary {
|
||||
background: linear-gradient(120deg, #ffb347, #ff6b8e);
|
||||
border: none; color: #2a1138;
|
||||
}
|
||||
|
||||
/* ---- base ---- */
|
||||
@@ -100,7 +208,7 @@ h1 { font-size: 2.1rem; }
|
||||
h1::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 88px; height: 8px;
|
||||
width: 100%; height: 8px;
|
||||
margin-top: 10px;
|
||||
background-image: var(--dither-dense);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
@@ -177,7 +285,8 @@ table, .badge, input[type=number] { font-variant-numeric: tabular-nums; }
|
||||
.topnav .brand:hover { text-decoration: none; }
|
||||
.topnav .links { display: flex; gap: 2px; flex: 1; flex-wrap: wrap; }
|
||||
.topnav .links a {
|
||||
color: var(--muted);
|
||||
/* brighter than --muted so menu items stay readable on dark themes */
|
||||
color: color-mix(in srgb, var(--text) 72%, var(--muted));
|
||||
padding: 7px 11px;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 500;
|
||||
@@ -196,6 +305,56 @@ table, .badge, input[type=number] { font-variant-numeric: tabular-nums; }
|
||||
}
|
||||
.topnav .right { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* ---- sidebar navigation (default; user can switch to top bar) ---- */
|
||||
body[data-nav=side] .topnav {
|
||||
justify-content: flex-end; /* only the right controls remain in the flow */
|
||||
padding-left: 232px;
|
||||
}
|
||||
body[data-nav=side] .topnav .brand {
|
||||
position: fixed; top: 14px; left: 16px; z-index: 41;
|
||||
font-size: 1rem;
|
||||
/* keep a long brand name inside the sidebar instead of overflowing it */
|
||||
width: 182px;
|
||||
white-space: normal;
|
||||
line-height: 1.15;
|
||||
}
|
||||
body[data-nav=side] .topnav .links {
|
||||
position: fixed; top: 0; left: 0; bottom: 0;
|
||||
width: 214px;
|
||||
flex: none; flex-direction: column; flex-wrap: nowrap; align-items: stretch;
|
||||
gap: 4px;
|
||||
background-color: var(--surface2);
|
||||
background-image: var(--dither);
|
||||
border-right: 2px solid var(--border);
|
||||
/* top padding clears the (possibly two-line) brand above */
|
||||
padding: 76px 12px 18px;
|
||||
overflow-y: auto;
|
||||
z-index: 40;
|
||||
}
|
||||
body[data-nav=side] .topnav .links a {
|
||||
border-bottom: none;
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 0;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
body[data-nav=side] .topnav .links a[aria-current=page] {
|
||||
border-left-color: var(--accent);
|
||||
background: var(--surface);
|
||||
}
|
||||
body[data-nav=side] main.container { margin-left: 214px; }
|
||||
@media (max-width: 760px) {
|
||||
/* collapse the sidebar back into a normal top bar on small screens */
|
||||
body[data-nav=side] .topnav { padding-left: 20px; justify-content: flex-start; flex-wrap: wrap; }
|
||||
body[data-nav=side] .topnav .brand { position: static; }
|
||||
body[data-nav=side] .topnav .links {
|
||||
position: static; width: auto; flex: 1; flex-direction: row; flex-wrap: wrap;
|
||||
background: none; border-right: none; padding: 0; overflow: visible;
|
||||
}
|
||||
body[data-nav=side] .topnav .links a { border-left: none; border-bottom: 2px solid transparent; }
|
||||
body[data-nav=side] .topnav .links a[aria-current=page] { border-bottom-color: var(--accent); background: none; }
|
||||
body[data-nav=side] main.container { margin-left: auto; }
|
||||
}
|
||||
|
||||
/* ---- cards: printed stock with hard Y2K shadows ---- */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
@@ -348,6 +507,22 @@ table.list tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surfa
|
||||
|
||||
.grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
||||
.grid > .card { display: flex; flex-direction: column; } /* equal-height rows */
|
||||
/* pin the action row to the bottom so buttons line up across cards */
|
||||
.grid > .card > .spread:last-child { margin-top: auto; }
|
||||
|
||||
/* Audit log: span the full screen width; long detail values wrap, never overflow */
|
||||
#tab-audit {
|
||||
width: calc(100vw - 32px);
|
||||
margin-left: calc(50% - 50vw + 16px);
|
||||
margin-right: calc(50% - 50vw + 16px);
|
||||
}
|
||||
body[data-nav=side] #tab-audit {
|
||||
width: calc(100vw - 214px - 32px);
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
#audit-table td, #audit-table th { white-space: normal; overflow-wrap: anywhere; vertical-align: top; }
|
||||
#audit-table td:last-child { font-family: var(--font-mono); font-size: 0.82rem; }
|
||||
|
||||
/* ---- toolbar: filter rows boxed and baseline-aligned ---- */
|
||||
.toolbar {
|
||||
@@ -391,6 +566,7 @@ table.list tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surfa
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
.spread { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
|
||||
.login-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 16px; }
|
||||
.stack > * + * { margin-top: 16px; }
|
||||
.mt { margin-top: 16px; }
|
||||
|
||||
@@ -458,8 +634,8 @@ table.list tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surfa
|
||||
}
|
||||
|
||||
/* ---- chat ---- */
|
||||
.chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 18px; min-height: 70vh; }
|
||||
.chat-sidebar { overflow: auto; }
|
||||
.chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 18px; height: calc(100vh - 150px); }
|
||||
.chat-sidebar { overflow: auto; min-height: 0; }
|
||||
.conv-list { list-style: none; margin: 8px 0 0; padding: 0; }
|
||||
.conv-list li { border-bottom: var(--hairline); }
|
||||
.conv-list li.active .conv-item {
|
||||
@@ -473,8 +649,10 @@ table.list tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surfa
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.conv-item:hover { background: var(--surface2); }
|
||||
.chat-main { display: flex; flex-direction: column; }
|
||||
.chat-messages { flex: 1; overflow: auto; padding: 8px 0; min-height: 320px; }
|
||||
.chat-main { display: flex; flex-direction: column; min-height: 0; }
|
||||
/* flex:1 + min-height:0 lets the message list scroll internally instead of
|
||||
growing the whole page */
|
||||
.chat-messages { flex: 1; overflow-y: auto; padding: 8px 0; min-height: 0; }
|
||||
.chat-msg {
|
||||
max-width: 75%; margin: 10px 0; padding: 9px 13px;
|
||||
background: var(--surface2);
|
||||
@@ -527,6 +705,26 @@ table.list tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surfa
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--muted);
|
||||
}
|
||||
/* Project (customer) label on bounty cards */
|
||||
.card-project {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--accent);
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
/* My Tasks: stack status columns vertically so each task gets full width */
|
||||
.kanban.mt { grid-template-columns: 1fr; }
|
||||
.kanban.mt .kanban-col { min-height: 0; }
|
||||
/* Card action rows: wrap and right-align so buttons never overflow the card */
|
||||
.kanban .card .spread { flex-wrap: wrap; }
|
||||
.kanban .card [data-actions] {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ---- notification bell ---- */
|
||||
.bell-badge {
|
||||
@@ -574,6 +772,44 @@ input[type=range] { width: 100%; accent-color: var(--accent); }
|
||||
border-color: color-mix(in srgb, var(--accent) 70%, black);
|
||||
}
|
||||
|
||||
/* ---- @-mention chips + links inside chat/comments ---- */
|
||||
.mention {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
padding: 0 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mention:hover {
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* links in messages and comments are underlined so they read as clickable */
|
||||
.chat-msg a:not(.btn), .cw-msg a:not(.btn), .comment-body a:not(.btn) {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ---- @-mention autocomplete menu ---- */
|
||||
.mention-menu {
|
||||
position: absolute; z-index: 80;
|
||||
background: var(--surface); border: var(--hairline);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--ink-shadow);
|
||||
max-height: 220px; overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
.mention-row {
|
||||
display: block; width: 100%; text-align: left;
|
||||
font: inherit; font-size: 0.9rem;
|
||||
background: none; border: none; color: var(--text);
|
||||
padding: 7px 10px; cursor: pointer; border-radius: var(--radius);
|
||||
}
|
||||
.mention-row.active, .mention-row:hover { background: var(--surface2); }
|
||||
|
||||
/* ---- dialogs: paper slips ---- */
|
||||
dialog {
|
||||
background: var(--surface); color: var(--text);
|
||||
@@ -625,6 +861,19 @@ legend {
|
||||
#nc-results .nc-empty { color: var(--muted); padding: 10px; font-size: 0.88rem; }
|
||||
#nc-selected { min-height: 30px; display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
/* members dialog: fixed width, vertically-stacked results (no horizontal growth) */
|
||||
#members-dialog > .stack { width: 440px; max-width: 86vw; }
|
||||
#mm-list li { justify-content: space-between; }
|
||||
#mm-results { max-height: 200px; overflow: auto; margin-top: 6px; }
|
||||
#mm-results .nc-row {
|
||||
display: block; width: 100%; text-align: left;
|
||||
font: inherit; font-size: 0.92rem;
|
||||
background: none; border: none; color: var(--text);
|
||||
padding: 8px 10px; cursor: pointer;
|
||||
border-bottom: var(--hairline); border-radius: var(--radius);
|
||||
}
|
||||
#mm-results .nc-row:hover { background: var(--surface2); }
|
||||
|
||||
/* ---- floating messages bubble + mini panel ---- */
|
||||
.chat-fab {
|
||||
position: fixed; bottom: 16px; right: 16px; z-index: 2147483645;
|
||||
|
||||
@@ -57,6 +57,36 @@ async function loadConsultants() {
|
||||
}));
|
||||
}
|
||||
|
||||
// Render one username input per selected consultant, prefilled from the
|
||||
// customer's saved per-consultant ticketing identity map.
|
||||
function renderIdentities() {
|
||||
const wrap = document.getElementById('c-identities-wrap');
|
||||
const host = document.getElementById('c-identities');
|
||||
const selectedIds = [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value);
|
||||
const existing = (editingCustomer && editingCustomer.ticketing.consultantIdentities) || {};
|
||||
if (!selectedIds.length) { wrap.hidden = true; host.replaceChildren(); return; }
|
||||
wrap.hidden = false;
|
||||
host.replaceChildren(...selectedIds.map((id) => {
|
||||
const u = consultants.find((c) => c.id === id) || { name: id, email: '' };
|
||||
const row = document.createElement('div');
|
||||
row.className = 'spread';
|
||||
row.style.marginTop = '6px';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'muted';
|
||||
label.style.flex = '1';
|
||||
label.textContent = u.name;
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'text';
|
||||
inp.dataset.identity = id;
|
||||
inp.placeholder = 'username in ticketing system';
|
||||
inp.value = existing[id] || '';
|
||||
inp.style.flex = '1';
|
||||
row.append(label, inp);
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
document.getElementById('c-consultants').addEventListener('change', renderIdentities);
|
||||
|
||||
function openCustomerDialog(customer) {
|
||||
editingCustomer = customer || null;
|
||||
document.getElementById('customer-dialog-title').textContent =
|
||||
@@ -73,6 +103,7 @@ function openCustomerDialog(customer) {
|
||||
[...sel.options].forEach((o) => {
|
||||
o.selected = customer ? customer.consultantIds.includes(o.value) : false;
|
||||
});
|
||||
renderIdentities();
|
||||
document.getElementById('c-test-result').textContent =
|
||||
customer && customer.ticketing.hasCredentials ? 'Stored credentials are kept unless you enter new ones.' : '';
|
||||
dlg.showModal();
|
||||
@@ -114,6 +145,11 @@ document.getElementById('customer-form').addEventListener('submit', async (e) =>
|
||||
defaultBudget: parseFloat(val('c-budget')) || 0,
|
||||
consultantIds: [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value),
|
||||
};
|
||||
const identities = {};
|
||||
document.querySelectorAll('#c-identities input[data-identity]').forEach((inp) => {
|
||||
if (inp.value.trim()) identities[inp.dataset.identity] = inp.value.trim();
|
||||
});
|
||||
body.consultantIdentities = identities;
|
||||
if (!editingCustomer || credsEntered(type)) body.credentials = credsFromForm(type);
|
||||
try {
|
||||
if (editingCustomer) {
|
||||
@@ -326,6 +362,7 @@ async function loadAudit(reset = true) {
|
||||
try {
|
||||
const entity = encodeURIComponent(document.getElementById('audit-entity').value.trim());
|
||||
if (reset) auditCursor = '';
|
||||
else if (!auditCursor) return; // already at the last page
|
||||
const res = await api('GET', `/api/v1/admin/audit-log?entityId=${entity}&cursor=${auditCursor}`);
|
||||
auditCursor = res.nextCursor;
|
||||
const rows = res.entries.map((e) => {
|
||||
@@ -344,6 +381,7 @@ async function loadAudit(reset = true) {
|
||||
const tbody = document.querySelector('#audit-table tbody');
|
||||
if (reset) tbody.replaceChildren(...rows);
|
||||
else tbody.append(...rows);
|
||||
document.getElementById('audit-more').hidden = !auditCursor;
|
||||
} catch (e) { showErr(e); }
|
||||
}
|
||||
document.getElementById('audit-apply').addEventListener('click', () => loadAudit(true));
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Archive page: archived tasks scoped by role (server-side), with search.
|
||||
import { api } from '/static/js/api.js';
|
||||
|
||||
const host = document.getElementById('archive-list');
|
||||
const errorBox = document.getElementById('error');
|
||||
const search = document.getElementById('archive-search');
|
||||
let names = {};
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
|
||||
function render(tasks) {
|
||||
host.replaceChildren(...tasks.map((t) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
const when = t.updatedAt ? new Date(t.updatedAt).toLocaleString() : '';
|
||||
card.innerHTML = `
|
||||
<div class="spread">
|
||||
${names[t.customerId] ? `<span class="card-project">${esc(names[t.customerId])}</span>` : '<span></span>'}
|
||||
<span class="badge accent">◈ ${t.bounty}</span>
|
||||
</div>
|
||||
<h3 class="mt"><a href="/tasks/${t.id}">${esc(t.title)}</a></h3>
|
||||
<p class="muted">${esc((t.description || '').slice(0, 200))}</p>
|
||||
<p class="muted" style="font-size:0.8rem">archived${when ? ' · ' + esc(when) : ''}</p>`;
|
||||
return card;
|
||||
}));
|
||||
if (!tasks.length) {
|
||||
host.innerHTML = '<p class="muted">No archived tasks match.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
async function load() {
|
||||
try {
|
||||
const q = search.value.trim();
|
||||
const res = await api('GET', '/api/v1/archive' + (q ? '?q=' + encodeURIComponent(q) : ''));
|
||||
names = res.customerNames || {};
|
||||
render(res.tasks || []);
|
||||
} catch (e) { errorBox.textContent = e.message; }
|
||||
}
|
||||
|
||||
search.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(load, 250); });
|
||||
load();
|
||||
@@ -11,6 +11,12 @@ let tasks = [];
|
||||
let atomizerUp = true;
|
||||
const selected = new Set();
|
||||
|
||||
// Hold Ctrl/Cmd while dragging an effort slider to rebalance siblings.
|
||||
let ctrlHeld = false;
|
||||
window.addEventListener('keydown', (e) => { if (e.key === 'Control' || e.key === 'Meta') ctrlHeld = true; });
|
||||
window.addEventListener('keyup', (e) => { if (e.key === 'Control' || e.key === 'Meta') ctrlHeld = false; });
|
||||
window.addEventListener('blur', () => { ctrlHeld = false; });
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
@@ -82,7 +88,7 @@ function renderRoot(root) {
|
||||
? '<span class="badge" style="color:var(--err)" title="No longer returned by the upstream system">orphaned</span>' : '';
|
||||
card.innerHTML = `
|
||||
<div class="spread">
|
||||
<h3>${icon} ${esc(root.title)} ${statusBadge(root)} ${orphan}</h3>
|
||||
<h3>${icon} <a href="/tasks/${root.id}">${esc(root.title)}</a> ${statusBadge(root)} ${orphan}</h3>
|
||||
<span class="muted">${esc(cust ? cust.name : '')} ${root.external ? '· ' + esc(root.external.key) : ''}</span>
|
||||
</div>
|
||||
<p class="muted">${esc((root.description || '').slice(0, 240))}</p>
|
||||
@@ -115,14 +121,20 @@ function renderChildren(parent, kids, depth) {
|
||||
wrap.className = 'stack mt';
|
||||
|
||||
const subdivided = kids.filter((k) => k.origin === 'subdivided');
|
||||
const sum = subdivided.reduce((acc, k) => acc + k.effortCoefficient, 0);
|
||||
const hasExtension = kids.some((k) => k.origin === 'extended');
|
||||
if (subdivided.length) {
|
||||
const ind = document.createElement('p');
|
||||
const bad = !hasExtension && Math.abs(sum - 1) > 0.005;
|
||||
ind.innerHTML = `Coefficient sum: <strong style="color:${bad ? 'var(--err)' : 'var(--ok)'}">${sum.toFixed(2)}</strong>` +
|
||||
const subSiblings = []; // {k, slider, coeffEl, bountyEl} for adjustable subdivided rows
|
||||
let ind = null;
|
||||
function updateSumIndicator() {
|
||||
if (!ind) return;
|
||||
const s = subSiblings.reduce((a, x) => a + parseFloat(x.slider.value), 0);
|
||||
const bad = !hasExtension && Math.abs(s - 1) > 0.005;
|
||||
ind.innerHTML = `Coefficient sum: <strong style="color:${bad ? 'var(--err)' : 'var(--ok)'}">${s.toFixed(2)}</strong>` +
|
||||
(hasExtension ? ' <span class="muted">(extensions allow > 1.00)</span>'
|
||||
: bad ? ' — should be 1.00' : '');
|
||||
: bad ? ' — should be 1.00' : '') +
|
||||
' <span class="muted" style="font-size:0.82rem">· hold Ctrl while dragging to rebalance the others</span>';
|
||||
}
|
||||
if (subdivided.length) {
|
||||
ind = document.createElement('p');
|
||||
wrap.appendChild(ind);
|
||||
}
|
||||
|
||||
@@ -135,7 +147,7 @@ function renderChildren(parent, kids, depth) {
|
||||
<div class="spread">
|
||||
<span>
|
||||
${k.status === 'atomized' ? `<input type="checkbox" data-sel aria-label="Select ${esc(k.title)} for publishing" ${selected.has(k.id) ? 'checked' : ''}>` : ''}
|
||||
<strong>${esc(k.title)}</strong> ${ext} ${statusBadge(k)}
|
||||
<a href="/tasks/${k.id}"><strong>${esc(k.title)}</strong></a> ${ext} ${statusBadge(k)}
|
||||
</span>
|
||||
<span>bounty <strong data-bounty>${k.bounty}</strong></span>
|
||||
</div>
|
||||
@@ -157,25 +169,54 @@ function renderChildren(parent, kids, depth) {
|
||||
</span>
|
||||
</div>`;
|
||||
const slider = row.querySelector('input[type=range]');
|
||||
const coeffEl = row.querySelector('[data-coeff]');
|
||||
const bountyEl = row.querySelector('[data-bounty]');
|
||||
if (k.origin === 'subdivided' && !slider.disabled) {
|
||||
subSiblings.push({ k, slider, coeffEl, bountyEl });
|
||||
}
|
||||
let sliderTimer = null;
|
||||
const round2 = (n) => Math.round(n * 100) / 100;
|
||||
slider.addEventListener('input', () => {
|
||||
row.querySelector('[data-coeff]').textContent = parseFloat(slider.value).toFixed(2);
|
||||
row.querySelector('[data-bounty]').textContent = (slider.value * k.budget).toFixed(2);
|
||||
const newVal = parseFloat(slider.value);
|
||||
coeffEl.textContent = newVal.toFixed(2);
|
||||
bountyEl.textContent = (newVal * k.budget).toFixed(2);
|
||||
const changed = [{ k, value: newVal }];
|
||||
// Ctrl/Cmd held: rebalance the other subdivided siblings so the sum
|
||||
// stays ~1.00 (increase one → the rest shrink proportionally).
|
||||
if (ctrlHeld && k.origin === 'subdivided') {
|
||||
const others = subSiblings.filter((s) => s.k.id !== k.id);
|
||||
const curOthers = others.reduce((a, s) => a + parseFloat(s.slider.value), 0);
|
||||
const targetOthers = Math.max(0, 1 - newVal);
|
||||
if (others.length && curOthers > 0) {
|
||||
const scale = targetOthers / curOthers;
|
||||
others.forEach((s) => {
|
||||
const max = parseFloat(s.slider.max);
|
||||
const nv = round2(Math.max(0.01, Math.min(max, parseFloat(s.slider.value) * scale)));
|
||||
s.slider.value = nv;
|
||||
s.coeffEl.textContent = nv.toFixed(2);
|
||||
s.bountyEl.textContent = (nv * s.k.budget).toFixed(2);
|
||||
changed.push({ k: s.k, value: nv });
|
||||
});
|
||||
}
|
||||
updateSumIndicator();
|
||||
}
|
||||
clearTimeout(sliderTimer);
|
||||
sliderTimer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await api('PATCH', `/api/v1/tasks/${k.id}`, {
|
||||
effortCoefficient: parseFloat(slider.value), version: k.version,
|
||||
});
|
||||
k.version = res.task.version;
|
||||
k.effortCoefficient = res.task.effortCoefficient;
|
||||
k.bounty = res.task.bounty;
|
||||
for (const ch of changed) {
|
||||
const res = await api('PATCH', `/api/v1/tasks/${ch.k.id}`, {
|
||||
effortCoefficient: ch.value, version: ch.k.version,
|
||||
});
|
||||
ch.k.version = res.task.version;
|
||||
ch.k.effortCoefficient = res.task.effortCoefficient;
|
||||
ch.k.bounty = res.task.bounty;
|
||||
}
|
||||
render();
|
||||
} catch (e) {
|
||||
if (e.status === 409) { toast('Task changed elsewhere — reloading.', 'err'); load(); }
|
||||
else toast(e.message, 'err');
|
||||
}
|
||||
}, 400);
|
||||
}, 450);
|
||||
});
|
||||
const sel = row.querySelector('[data-sel]');
|
||||
if (sel) {
|
||||
@@ -200,6 +241,7 @@ function renderChildren(parent, kids, depth) {
|
||||
const grand = childrenOf(k.id);
|
||||
if (grand.length) wrap.appendChild(renderChildren(k, grand, depth + 1));
|
||||
});
|
||||
updateSumIndicator();
|
||||
return wrap;
|
||||
}
|
||||
|
||||
|
||||
+10
-3
@@ -4,7 +4,9 @@ import { subscribe, onPollFallback } from '/static/js/ws.js';
|
||||
const grid = document.getElementById('board-grid');
|
||||
const errorBox = document.getElementById('error');
|
||||
let meId = '';
|
||||
let isDeveloper = false;
|
||||
let tasks = [];
|
||||
const customerNames = new Map();
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
@@ -25,6 +27,7 @@ async function load() {
|
||||
const qs = new URLSearchParams(f).toString();
|
||||
const res = await api('GET', '/api/v1/board?' + qs);
|
||||
tasks = res.tasks;
|
||||
(res.customers || []).forEach((c) => customerNames.set(c.id, c.name));
|
||||
const sel = document.getElementById('f-customer');
|
||||
if (sel.options.length === 1) {
|
||||
res.customers.forEach((c) => sel.add(new Option(c.name, c.id)));
|
||||
@@ -58,6 +61,7 @@ function renderCard(t) {
|
||||
<span class="badge accent" style="font-size:1rem">◈ ${t.bounty}</span>
|
||||
<span>${ageBadge(t)}</span>
|
||||
</div>
|
||||
${customerNames.has(t.customerId) ? `<p class="muted card-project">${esc(customerNames.get(t.customerId))}</p>` : ''}
|
||||
<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>
|
||||
@@ -66,9 +70,11 @@ function renderCard(t) {
|
||||
${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>'}
|
||||
${!isDeveloper
|
||||
? '<a class="btn small" href="/tasks/' + t.id + '">Open</a>'
|
||||
: 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));
|
||||
@@ -118,6 +124,7 @@ async function restoreFilters() {
|
||||
try {
|
||||
const me = await api('GET', '/api/v1/auth/me');
|
||||
meId = me.user.id;
|
||||
isDeveloper = !!(me.user.roles && me.user.roles.developer);
|
||||
const saved = me.user.extra && me.user.extra.savedBoardFilters;
|
||||
if (saved) {
|
||||
const f = JSON.parse(saved);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// plain-text composer. Reuses the conversations API + live WS channel.
|
||||
import { api } from '/static/js/api.js';
|
||||
import { subscribe, onPollFallback } from '/static/js/ws.js';
|
||||
import { attachMentions, mentionHTML } from '/static/js/mention.js';
|
||||
|
||||
if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages') {
|
||||
let meId = '';
|
||||
@@ -112,14 +113,17 @@ if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages')
|
||||
input.value = '';
|
||||
try {
|
||||
await api('POST', `/api/v1/conversations/${current.id}/messages`, {
|
||||
body: '<p>' + esc(text) + '</p>',
|
||||
body: '<p>' + mentionHTML(text, input) + '</p>',
|
||||
});
|
||||
} catch (e) { input.value = text; }
|
||||
}
|
||||
panel.querySelector('#cw-send').addEventListener('click', send);
|
||||
panel.querySelector('#cw-input').addEventListener('keydown', (e) => {
|
||||
const cwInput = panel.querySelector('#cw-input');
|
||||
cwInput.addEventListener('keydown', (e) => {
|
||||
if (cwInput.dataset.mentionActive === '1') return; // mention picker owns Enter
|
||||
if (e.key === 'Enter') { e.preventDefault(); send(); }
|
||||
});
|
||||
attachMentions(cwInput);
|
||||
|
||||
fab.addEventListener('click', async () => {
|
||||
open = !open;
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// @-mention autocomplete. Attaches to a <textarea>/<input> or a
|
||||
// contenteditable element: typing "@" + letters shows a user picker; choosing
|
||||
// one inserts "@Name ". While the menu is open the host element has
|
||||
// data-mention-active="1" so the host's own Enter handler can defer to us.
|
||||
import { api } from '/static/js/api.js';
|
||||
|
||||
const TOKEN_RE = /(?:^|\s)@([\w.\-]{0,30})$/;
|
||||
|
||||
export function attachMentions(el) {
|
||||
const isCE = el.isContentEditable;
|
||||
// drop any stale menu left over from a previous attach on the same field
|
||||
// (task/comment views recreate their composer on every re-render)
|
||||
if (el.id) {
|
||||
document.querySelectorAll(`.mention-menu[data-mention-for="${el.id}"]`).forEach((m) => m.remove());
|
||||
}
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'mention-menu';
|
||||
menu.hidden = true;
|
||||
if (el.id) menu.dataset.mentionFor = el.id;
|
||||
document.body.appendChild(menu);
|
||||
|
||||
let items = [];
|
||||
let active = 0;
|
||||
let seq = 0;
|
||||
el._mentions = el._mentions || new Map(); // display name -> user id
|
||||
|
||||
function close() {
|
||||
menu.hidden = true;
|
||||
items = [];
|
||||
delete el.dataset.mentionActive;
|
||||
}
|
||||
|
||||
function context() {
|
||||
if (isCE) {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || !sel.rangeCount) return null;
|
||||
const range = sel.getRangeAt(0);
|
||||
const node = range.startContainer;
|
||||
if (node.nodeType !== 3) return null;
|
||||
const before = node.textContent.slice(0, range.startOffset);
|
||||
const m = before.match(TOKEN_RE);
|
||||
if (!m) return null;
|
||||
return { query: m[1], node, end: range.startOffset, start: range.startOffset - m[1].length - 1 };
|
||||
}
|
||||
const pos = el.selectionStart;
|
||||
const m = el.value.slice(0, pos).match(TOKEN_RE);
|
||||
if (!m) return null;
|
||||
return { query: m[1], end: pos, start: pos - m[1].length - 1 };
|
||||
}
|
||||
|
||||
async function update() {
|
||||
const ctx = context();
|
||||
if (!ctx) { close(); return; }
|
||||
const mySeq = ++seq;
|
||||
let users;
|
||||
try { users = (await api('GET', `/api/v1/users?q=${encodeURIComponent(ctx.query)}`)).users; }
|
||||
catch (e) { return; }
|
||||
if (mySeq !== seq) return; // a newer keystroke superseded this fetch
|
||||
const ctx2 = context();
|
||||
if (!ctx2) { close(); return; }
|
||||
items = users.slice(0, 6);
|
||||
if (!items.length) { close(); return; }
|
||||
active = 0;
|
||||
render(ctx2);
|
||||
}
|
||||
|
||||
function render(ctx) {
|
||||
menu.replaceChildren(...items.map((u, i) => {
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'mention-row' + (i === active ? ' active' : '');
|
||||
row.innerHTML = `<strong>${escapeHtml(u.name)}</strong> <span class="muted">${escapeHtml(u.email)}</span>`;
|
||||
row.addEventListener('mousedown', (e) => { e.preventDefault(); pick(u, ctx); });
|
||||
return row;
|
||||
}));
|
||||
position();
|
||||
menu.hidden = false;
|
||||
el.dataset.mentionActive = '1';
|
||||
}
|
||||
|
||||
function position() {
|
||||
const rect = el.getBoundingClientRect();
|
||||
menu.style.left = `${rect.left + window.scrollX}px`;
|
||||
menu.style.top = `${rect.bottom + window.scrollY + 2}px`;
|
||||
menu.style.minWidth = `${Math.min(Math.max(rect.width, 220), 360)}px`;
|
||||
}
|
||||
|
||||
function highlight() {
|
||||
[...menu.children].forEach((c, i) => c.classList.toggle('active', i === active));
|
||||
}
|
||||
|
||||
function pick(u, ctx) {
|
||||
el._mentions.set(u.name, u.id);
|
||||
if (isCE) {
|
||||
// insert an atomic mention chip + a trailing space in the text node
|
||||
const node = ctx.node;
|
||||
const full = node.textContent;
|
||||
const after = full.slice(ctx.end);
|
||||
node.textContent = full.slice(0, ctx.start);
|
||||
const span = document.createElement('span');
|
||||
span.className = 'mention';
|
||||
span.dataset.userCard = u.id;
|
||||
span.contentEditable = 'false';
|
||||
span.textContent = '@' + u.name;
|
||||
// the chip is an atomic, non-editable element, so the trailing space in
|
||||
// this separate text node stays put as the user keeps typing
|
||||
const tail = document.createTextNode(' ' + after);
|
||||
const parent = node.parentNode;
|
||||
const ref = node.nextSibling;
|
||||
parent.insertBefore(span, ref);
|
||||
parent.insertBefore(tail, ref);
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.setStart(tail, 1);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
} else {
|
||||
const text = '@' + u.name + ' ';
|
||||
const v = el.value;
|
||||
el.value = v.slice(0, ctx.start) + text + v.slice(ctx.end);
|
||||
const caret = ctx.start + text.length;
|
||||
el.setSelectionRange(caret, caret);
|
||||
}
|
||||
close();
|
||||
el.focus();
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
el.addEventListener('input', update);
|
||||
el.addEventListener('keydown', (e) => {
|
||||
if (menu.hidden) return;
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); active = (active + 1) % items.length; highlight(); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); active = (active - 1 + items.length) % items.length; highlight(); }
|
||||
else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); pick(items[active], context()); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); close(); }
|
||||
});
|
||||
el.addEventListener('blur', () => setTimeout(close, 150));
|
||||
}
|
||||
|
||||
// mentionHTML turns the plain text of a textarea/input into safe HTML, wrapping
|
||||
// any recorded "@Name" run in a mention span carrying the user id. Names with
|
||||
// spaces are matched exactly (longest first) against what the picker inserted.
|
||||
export function mentionHTML(text, el) {
|
||||
const mentions = (el && el._mentions) || new Map();
|
||||
const names = [...mentions.keys()].sort((a, b) => b.length - a.length);
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
if (text[i] === '@') {
|
||||
const name = names.find((n) => text.startsWith('@' + n, i));
|
||||
if (name) {
|
||||
out += `<span class="mention" data-user-card="${escapeHtml(mentions.get(name))}">@${escapeHtml(name)}</span>`;
|
||||
i += 1 + name.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out += escapeHtml(text[i]);
|
||||
i += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
}
|
||||
+135
-4
@@ -1,5 +1,6 @@
|
||||
import { api, toast } from '/static/js/api.js';
|
||||
import { subscribe, send as wsSend, onPollFallback } from '/static/js/ws.js';
|
||||
import { attachMentions } from '/static/js/mention.js';
|
||||
|
||||
const errorBox = document.getElementById('error');
|
||||
const convList = document.getElementById('conv-list');
|
||||
@@ -61,7 +62,10 @@ function renderConvList() {
|
||||
async function openConversation(c) {
|
||||
current = c;
|
||||
oldestCursor = '';
|
||||
noMoreOlder = false;
|
||||
document.getElementById('chat-title').textContent = c.title;
|
||||
const mbtn = document.getElementById('chat-members');
|
||||
mbtn.hidden = !(c.kind !== 'dm' && c.creatorId && c.creatorId === meId);
|
||||
form.hidden = false;
|
||||
msgHost.replaceChildren();
|
||||
renderConvList();
|
||||
@@ -72,19 +76,36 @@ async function openConversation(c) {
|
||||
history.replaceState(null, '', '/messages?c=' + c.id);
|
||||
}
|
||||
|
||||
const MSG_PAGE = 40;
|
||||
async function loadMessages(prepend) {
|
||||
const res = await api('GET',
|
||||
`/api/v1/conversations/${current.id}/messages?cursor=${prepend ? oldestCursor : ''}`);
|
||||
`/api/v1/conversations/${current.id}/messages?limit=${MSG_PAGE}&cursor=${prepend ? oldestCursor : ''}`);
|
||||
const msgs = res.messages;
|
||||
if (prepend && msgs.length < MSG_PAGE) noMoreOlder = true;
|
||||
if (msgs.length) oldestCursor = msgs[0].id;
|
||||
const nodes = await Promise.all(msgs.map(renderMessage));
|
||||
if (prepend) msgHost.prepend(...nodes);
|
||||
else {
|
||||
if (prepend) {
|
||||
// preserve the scroll position so the view doesn't jump when older
|
||||
// messages are inserted above the current viewport
|
||||
const before = msgHost.scrollHeight;
|
||||
const top = msgHost.scrollTop;
|
||||
msgHost.prepend(...nodes);
|
||||
msgHost.scrollTop = top + (msgHost.scrollHeight - before);
|
||||
} else {
|
||||
msgHost.append(...nodes);
|
||||
msgHost.scrollTop = msgHost.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// load older history when the user scrolls near the top
|
||||
let loadingOlder = false;
|
||||
let noMoreOlder = false;
|
||||
msgHost.addEventListener('scroll', async () => {
|
||||
if (msgHost.scrollTop > 60 || loadingOlder || noMoreOlder || !current) return;
|
||||
loadingOlder = true;
|
||||
try { await loadMessages(true); } finally { loadingOlder = false; }
|
||||
});
|
||||
|
||||
// ULID ids embed a 48-bit ms timestamp in the first 10 chars.
|
||||
function ulidTime(id) {
|
||||
const A = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||||
@@ -126,11 +147,13 @@ document.querySelectorAll('[data-cmd]').forEach((btn) => {
|
||||
});
|
||||
|
||||
composer.addEventListener('keydown', (e) => {
|
||||
if (composer.dataset.mentionActive === '1') return; // mention picker owns Enter
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
form.requestSubmit();
|
||||
}
|
||||
});
|
||||
attachMentions(composer);
|
||||
|
||||
let typingTimer = null;
|
||||
composer.addEventListener('input', () => {
|
||||
@@ -197,8 +220,9 @@ const typingNames = new Map();
|
||||
subscribe('chat', async (event, data) => {
|
||||
if (event === 'message' && data) {
|
||||
if (current && data.conversationId === current.id) {
|
||||
const nearBottom = msgHost.scrollHeight - msgHost.scrollTop - msgHost.clientHeight < 120;
|
||||
msgHost.appendChild(await renderMessage(data));
|
||||
msgHost.scrollTop = msgHost.scrollHeight;
|
||||
if (nearBottom) msgHost.scrollTop = msgHost.scrollHeight;
|
||||
api('POST', `/api/v1/conversations/${current.id}/read`, {}).catch(() => {});
|
||||
} else {
|
||||
loadConversations();
|
||||
@@ -297,6 +321,113 @@ document.getElementById('newconv-form').addEventListener('submit', async (e) =>
|
||||
} catch (err) { toast(err.message, 'err'); }
|
||||
});
|
||||
|
||||
// ---- message search ----
|
||||
const convSearch = document.getElementById('conv-search');
|
||||
let msgSearchTimer = null;
|
||||
async function runMessageSearch(q) {
|
||||
const list = document.getElementById('conv-list');
|
||||
const results = document.getElementById('search-results');
|
||||
if (q.length < 2) { results.hidden = true; results.replaceChildren(); list.hidden = false; return; }
|
||||
try {
|
||||
const res = await api('GET', `/api/v1/messages/search?q=${encodeURIComponent(q)}`);
|
||||
list.hidden = true; results.hidden = false;
|
||||
results.replaceChildren(...res.results.map((r) => {
|
||||
const li = document.createElement('li');
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'conv-item';
|
||||
b.style.cssText = 'flex-direction:column;align-items:flex-start;gap:2px';
|
||||
b.innerHTML = `<span>${esc(r.conversationTitle)}</span>
|
||||
<span class="muted" style="font-size:0.82rem">${esc(r.snippet)}</span>`;
|
||||
b.addEventListener('click', () => {
|
||||
const c = conversations.find((x) => x.id === r.conversationId);
|
||||
if (c) {
|
||||
convSearch.value = '';
|
||||
results.hidden = true; list.hidden = false;
|
||||
openConversation(c);
|
||||
}
|
||||
});
|
||||
li.appendChild(b);
|
||||
return li;
|
||||
}));
|
||||
if (!res.results.length) {
|
||||
results.innerHTML = '<li class="muted" style="padding:10px">No matching messages.</li>';
|
||||
}
|
||||
} catch (e) { /* ignore transient search errors */ }
|
||||
}
|
||||
convSearch.addEventListener('input', () => {
|
||||
clearTimeout(msgSearchTimer);
|
||||
const q = convSearch.value.trim();
|
||||
msgSearchTimer = setTimeout(() => runMessageSearch(q), 250);
|
||||
});
|
||||
|
||||
// ---- manage members (group creator only) ----
|
||||
const membersDlg = document.getElementById('members-dialog');
|
||||
let mmMemberIds = new Set();
|
||||
|
||||
async function renderMembers() {
|
||||
const res = await api('GET', `/api/v1/conversations/${current.id}/members`);
|
||||
mmMemberIds = new Set(res.members.map((u) => u.id));
|
||||
const host = document.getElementById('mm-list');
|
||||
host.replaceChildren(...res.members.map((u) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'conv-item';
|
||||
li.style.justifyContent = 'space-between';
|
||||
li.innerHTML = `<span>${esc(u.name)} ${u.isCreator ? '<span class="badge accent">creator</span>' : ''}
|
||||
<br><span class="muted">${esc(u.email)}</span></span>`;
|
||||
if (res.canManage && !u.isCreator) {
|
||||
const rm = document.createElement('button');
|
||||
rm.className = 'btn small';
|
||||
rm.textContent = 'Remove';
|
||||
rm.addEventListener('click', async () => {
|
||||
try {
|
||||
await api('DELETE', `/api/v1/conversations/${current.id}/members/${u.id}`);
|
||||
await renderMembers();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
li.appendChild(rm);
|
||||
}
|
||||
return li;
|
||||
}));
|
||||
document.getElementById('mm-add-wrap').hidden = !res.canManage;
|
||||
}
|
||||
|
||||
let mmTimer = null;
|
||||
async function mmSearch() {
|
||||
const q = document.getElementById('mm-search').value.trim();
|
||||
const res = await api('GET', `/api/v1/users?q=${encodeURIComponent(q)}`);
|
||||
const host = document.getElementById('mm-results');
|
||||
const rows = res.users.filter((u) => !mmMemberIds.has(u.id)).map((u) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'nc-row';
|
||||
b.textContent = `${u.name} — ${u.email}`;
|
||||
b.addEventListener('click', async () => {
|
||||
try {
|
||||
await api('POST', `/api/v1/conversations/${current.id}/members`, { userId: u.id });
|
||||
document.getElementById('mm-search').value = '';
|
||||
host.replaceChildren();
|
||||
await renderMembers();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
return b;
|
||||
});
|
||||
host.replaceChildren(...rows);
|
||||
}
|
||||
|
||||
document.getElementById('chat-members').addEventListener('click', async () => {
|
||||
if (!current) return;
|
||||
document.getElementById('mm-search').value = '';
|
||||
document.getElementById('mm-results').replaceChildren();
|
||||
try { await renderMembers(); membersDlg.showModal(); }
|
||||
catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
document.getElementById('mm-close').addEventListener('click', () => membersDlg.close());
|
||||
document.getElementById('mm-search').addEventListener('input', () => {
|
||||
clearTimeout(mmTimer);
|
||||
mmTimer = setTimeout(mmSearch, 250);
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const me = await api('GET', '/api/v1/auth/me');
|
||||
|
||||
@@ -16,9 +16,12 @@ const themeBtn = document.getElementById('nav-theme');
|
||||
if (themeBtn) {
|
||||
themeBtn.addEventListener('click', async () => {
|
||||
const el = document.documentElement;
|
||||
const next = el.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
const themes = (el.dataset.themes || 'light,dark').split(',');
|
||||
const cur = themes.indexOf(el.dataset.theme);
|
||||
const next = themes[(cur + 1) % themes.length];
|
||||
el.dataset.theme = next;
|
||||
try { localStorage.setItem('theme', next); } catch (e) { /* ignore */ }
|
||||
toast('Theme: ' + next);
|
||||
if (document.body.dataset.loggedIn === '1') {
|
||||
try {
|
||||
await api('PATCH', '/api/v1/profile', { settings: { theme: next } });
|
||||
|
||||
@@ -56,12 +56,16 @@ form.addEventListener('submit', async (e) => {
|
||||
links,
|
||||
},
|
||||
extra,
|
||||
settings: { theme: document.getElementById('p-theme').value },
|
||||
settings: {
|
||||
theme: document.getElementById('p-theme').value,
|
||||
navLayout: document.getElementById('p-nav').value,
|
||||
},
|
||||
});
|
||||
version = res.user.version;
|
||||
const theme = res.user.settings.theme;
|
||||
document.documentElement.dataset.theme = theme;
|
||||
try { localStorage.setItem('theme', theme); } catch (err) { /* ignore */ }
|
||||
document.body.dataset.nav = res.user.settings.navLayout || 'side';
|
||||
okBox.textContent = 'Profile saved.';
|
||||
} catch (err) {
|
||||
if (err.status === 409) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Public profile view (/users/{id}): full bio, contact, links and custom
|
||||
// details from the user's profile card.
|
||||
import { api } from '/static/js/api.js';
|
||||
|
||||
const root = document.getElementById('profile-root');
|
||||
const errorBox = document.getElementById('error');
|
||||
const uid = root.dataset.userId;
|
||||
|
||||
const HIDDEN_EXTRA = new Set(['ticketingIdentities', 'savedBoardFilters']);
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
|
||||
// only allow safe link schemes; bare domains get https://, others are blocked
|
||||
export function safeUrl(u) {
|
||||
const s = String(u ?? '').trim();
|
||||
if (/^(https?:\/\/|mailto:)/i.test(s)) return s;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return '#';
|
||||
return s ? 'https://' + s : '#';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await api('GET', `/api/v1/users/${uid}/card`);
|
||||
const c = res.card;
|
||||
const roles = ['admin', 'consultant', 'developer'].filter((r) => c.roles && c.roles[r]);
|
||||
const links = ((c.contact && c.contact.links) || []).filter(Boolean);
|
||||
const extra = Object.entries(c.extra || {})
|
||||
.filter(([k, v]) => !HIDDEN_EXTRA.has(k) && v !== null && typeof v !== 'object');
|
||||
const hasContact = (c.contact && (c.contact.phone || c.contact.location)) || links.length;
|
||||
root.innerHTML = `
|
||||
<div class="card">
|
||||
<div style="display:flex;gap:16px;align-items:center">
|
||||
${c.avatarFileId
|
||||
? `<img class="avatar large" src="/files/${c.avatarFileId}" alt="">`
|
||||
: `<span class="avatar large">${esc((c.name || '?').slice(0, 1).toUpperCase())}</span>`}
|
||||
<div>
|
||||
<h1 style="margin:0">${esc(c.name)}</h1>
|
||||
<p style="margin:4px 0">${roles.map((r) => `<span class="badge">${r}</span>`).join(' ')}</p>
|
||||
</div>
|
||||
</div>
|
||||
${c.bio ? `<h2 class="mt">Bio</h2><p style="white-space:pre-wrap">${esc(c.bio)}</p>` : ''}
|
||||
${hasContact ? '<h2 class="mt">Contact</h2>' : ''}
|
||||
${c.contact && c.contact.location ? `<p class="muted">📍 ${esc(c.contact.location)}</p>` : ''}
|
||||
${c.contact && c.contact.phone ? `<p class="muted">📞 ${esc(c.contact.phone)}</p>` : ''}
|
||||
${links.length ? `<ul>${links.map((l) =>
|
||||
`<li><a href="${esc(safeUrl(l))}" target="_blank" rel="noopener">${esc(l)}</a></li>`).join('')}</ul>` : ''}
|
||||
${extra.length ? `<h2 class="mt">Details</h2>${extra.map(([k, v]) =>
|
||||
`<p class="muted"><strong>${esc(k)}:</strong> ${esc(String(v))}</p>`).join('')}` : ''}
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
errorBox.textContent = 'Could not load this profile.';
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
@@ -26,7 +26,7 @@ async function load() {
|
||||
return card;
|
||||
}));
|
||||
if (!res.tasks.length) {
|
||||
host.innerHTML = '<p class="muted">Nothing waiting for review. 🎉</p>';
|
||||
host.innerHTML = '<p class="muted">Nothing waiting for review.</p>';
|
||||
}
|
||||
} catch (e) { errorBox.textContent = e.message; }
|
||||
}
|
||||
|
||||
@@ -37,6 +37,14 @@ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
|
||||
// allow only safe link schemes (block javascript:/data: etc.)
|
||||
const safeUrl = (u) => {
|
||||
const s = String(u ?? '').trim();
|
||||
if (/^(https?:\/\/|mailto:)/i.test(s)) return s;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return '#';
|
||||
return s ? 'https://' + s : '#';
|
||||
};
|
||||
|
||||
function hideCard() {
|
||||
if (cardEl) { cardEl.remove(); cardEl = null; }
|
||||
}
|
||||
@@ -49,6 +57,7 @@ async function showCard(target, userId) {
|
||||
cardEl = document.createElement('div');
|
||||
cardEl.className = 'hovercard card';
|
||||
const roles = ['admin', 'consultant', 'developer'].filter((r) => c.roles[r]);
|
||||
const links = ((c.contact && c.contact.links) || []).filter(Boolean);
|
||||
cardEl.innerHTML = `
|
||||
<div class="spread">
|
||||
${c.avatarFileId ? `<img class="avatar large" src="/files/${c.avatarFileId}" alt="">`
|
||||
@@ -58,8 +67,12 @@ async function showCard(target, userId) {
|
||||
${roles.map((r) => `<span class="badge">${r}</span>`).join(' ')}
|
||||
</div>
|
||||
</div>
|
||||
${c.bio ? `<p class="muted">${esc(c.bio.slice(0, 160))}</p>` : ''}
|
||||
${c.contact && c.contact.location ? `<p class="muted">📍 ${esc(c.contact.location)}</p>` : ''}`;
|
||||
${c.bio ? `<p class="muted">${esc(c.bio.slice(0, 220))}${c.bio.length > 220 ? '…' : ''}</p>` : ''}
|
||||
${c.contact && c.contact.location ? `<p class="muted">📍 ${esc(c.contact.location)}</p>` : ''}
|
||||
${c.contact && c.contact.phone ? `<p class="muted">📞 ${esc(c.contact.phone)}</p>` : ''}
|
||||
${links.length ? `<p class="muted">${links.slice(0, 3).map((l) =>
|
||||
`<a href="${esc(safeUrl(l))}" target="_blank" rel="noopener">🔗 ${esc(l)}</a>`).join('<br>')}</p>` : ''}
|
||||
<a class="btn small mt" href="/users/${encodeURIComponent(userId)}">See full profile →</a>`;
|
||||
document.body.appendChild(cardEl);
|
||||
const rect = target.getBoundingClientRect();
|
||||
cardEl.style.left = Math.min(rect.left, window.innerWidth - 320) + 'px';
|
||||
@@ -81,3 +94,11 @@ document.addEventListener('mouseout', (e) => {
|
||||
hideTimer = setTimeout(hideCard, 250);
|
||||
}
|
||||
});
|
||||
// click a mention (or any user-card target) to open its card immediately
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target.closest('[data-user-card]');
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
clearTimeout(hideTimer);
|
||||
showCard(target, target.dataset.userCard);
|
||||
});
|
||||
|
||||
+18
-3
@@ -1,5 +1,6 @@
|
||||
import { api, toast } from '/static/js/api.js';
|
||||
import { subscribe } from '/static/js/ws.js';
|
||||
import { attachMentions, mentionHTML } from '/static/js/mention.js';
|
||||
|
||||
const root = document.getElementById('task-root');
|
||||
const errorBox = document.getElementById('error');
|
||||
@@ -57,7 +58,7 @@ async function render() {
|
||||
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 class="comment-body">${c.body}</div>
|
||||
</div>`));
|
||||
|
||||
const timeline = await Promise.all((t.timeline || []).slice().reverse().map(async (e) => `
|
||||
@@ -146,6 +147,18 @@ function renderActions() {
|
||||
catch (e) { toast(e.message, 'err'); }
|
||||
};
|
||||
|
||||
// link to the upstream ticket (same affordance as the atomization board)
|
||||
if (t.external && t.external.url) {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'btn';
|
||||
a.style.marginRight = '8px';
|
||||
a.href = t.external.url;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
a.textContent = 'Source ↗';
|
||||
host.appendChild(a);
|
||||
}
|
||||
|
||||
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'));
|
||||
@@ -182,12 +195,14 @@ function wireHandlers() {
|
||||
});
|
||||
document.getElementById('comment-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const body = document.getElementById('comment-body').value;
|
||||
const field = document.getElementById('comment-body');
|
||||
const body = mentionHTML(field.value, field).replace(/\n/g, '<br>');
|
||||
try {
|
||||
await api('POST', `/api/v1/tasks/${taskId}/comments`, { body: '<p>' + esc(body).replace(/\n/g, '<br>') + '</p>' });
|
||||
await api('POST', `/api/v1/tasks/${taskId}/comments`, { body: '<p>' + body + '</p>' });
|
||||
load();
|
||||
} catch (err) { toast(err.message, 'err'); }
|
||||
});
|
||||
attachMentions(document.getElementById('comment-body'));
|
||||
const timeForm = document.getElementById('time-form');
|
||||
if (timeForm) {
|
||||
timeForm.addEventListener('submit', async (e) => {
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
var el = document.documentElement;
|
||||
var saved = null;
|
||||
try { saved = localStorage.getItem('theme'); } catch (e) { /* private mode */ }
|
||||
var THEMES = ['light', 'dark', 'neon', 'terminal', 'blueprint', 'sunset'];
|
||||
var theme = saved || el.dataset.defaultTheme || 'light';
|
||||
if (theme !== 'light' && theme !== 'dark') theme = 'light';
|
||||
if (THEMES.indexOf(theme) === -1) theme = 'light';
|
||||
el.dataset.theme = theme;
|
||||
el.dataset.themes = THEMES.join(',');
|
||||
})();
|
||||
|
||||
@@ -84,6 +84,11 @@
|
||||
<select id="c-consultants" multiple size="4"></select>
|
||||
<p class="hint">Hold Ctrl/Cmd to select multiple. At least one is needed for syncing.</p>
|
||||
</div>
|
||||
<div class="field" id="c-identities-wrap" hidden>
|
||||
<label>Ticketing account mapping</label>
|
||||
<p class="hint">Username in this project's ticketing system for each assigned consultant. Overrides the consultant's global identity. Leave blank to use their global one.</p>
|
||||
<div id="c-identities"></div>
|
||||
</div>
|
||||
<div class="spread">
|
||||
<span id="c-test-result" class="hint" aria-live="polite"></span>
|
||||
<span>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{{define "content"}}
|
||||
<h1>Archive</h1>
|
||||
<div class="error-box" id="error" role="alert"></div>
|
||||
<input type="search" id="archive-search" class="mt"
|
||||
placeholder="Search archived tasks…" aria-label="Search archived tasks"
|
||||
style="max-width:440px; width:100%">
|
||||
<p class="muted" style="margin-top:6px">Archived tasks you can access.</p>
|
||||
<div class="grid mt" id="archive-list"></div>
|
||||
{{end}}
|
||||
@@ -3,18 +3,20 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} · Bounty Board</title>
|
||||
<title>{{.Title}} · {{.Brand}}</title>
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
<script src="/static/js/theme.js"></script>
|
||||
</head>
|
||||
<body {{if .User}}data-logged-in="1"{{end}}>
|
||||
<body {{if .User}}data-logged-in="1"{{end}} data-nav="{{if .User}}{{.NavLayout}}{{else}}top{{end}}">
|
||||
<nav class="topnav">
|
||||
<a class="brand" href="/">Bounty Board</a>
|
||||
<a class="brand" href="/">{{.Brand}}</a>
|
||||
<div class="links">
|
||||
{{if .User}}
|
||||
{{if .User.Roles.Developer}}
|
||||
<a href="/board" {{if eq .Active "board"}}aria-current="page"{{end}}>Bounty Board</a>
|
||||
<a href="/my-tasks" {{if eq .Active "my-tasks"}}aria-current="page"{{end}}>My Tasks</a>
|
||||
{{else if .User.Roles.Consultant}}
|
||||
<a href="/board" {{if eq .Active "board"}}aria-current="page"{{end}}>Bounty Board</a>
|
||||
{{end}}
|
||||
{{if .User.Roles.Consultant}}
|
||||
<a href="/consultant/board" {{if eq .Active "atomization"}}aria-current="page"{{end}}>Atomization</a>
|
||||
@@ -26,6 +28,7 @@
|
||||
{{end}}
|
||||
<a href="/messages" {{if eq .Active "messages"}}aria-current="page"{{end}}>Messages</a>
|
||||
<a href="/metrics" {{if eq .Active "metrics"}}aria-current="page"{{end}}>Metrics</a>
|
||||
<a href="/archive" {{if eq .Active "archive"}}aria-current="page"{{end}}>Archive</a>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="right">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{{define "content"}}
|
||||
<header class="masthead">
|
||||
<p class="masthead-kicker">Consulting Work Exchange</p>
|
||||
<p class="masthead-title">◈ Bounty Board</p>
|
||||
<p class="masthead-title">◈ {{.Brand}}</p>
|
||||
<p class="masthead-rule">❦</p>
|
||||
</header>
|
||||
<div class="card">
|
||||
@@ -16,15 +16,13 @@
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<div class="spread">
|
||||
<div class="login-actions">
|
||||
<button class="btn primary" type="submit">Log in</button>
|
||||
<span><a href="/forgot-password">Forgot password?</a> · <a href="/register">Create an account</a></span>
|
||||
{{if .OIDCEnabled}}<a class="btn" href="/api/v1/auth/oidc/login">Continue with SSO</a>{{end}}
|
||||
</div>
|
||||
</form>
|
||||
{{if .OIDCEnabled}}
|
||||
<div class="mt">
|
||||
<a class="btn" href="/api/v1/auth/oidc/login">Continue with SSO</a>
|
||||
</div>
|
||||
{{end}}
|
||||
<p class="muted mt" style="margin-bottom:0">
|
||||
<a href="/forgot-password">Forgot password?</a> · <a href="/register">Create an account</a>
|
||||
</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -6,12 +6,17 @@
|
||||
<h2>Messages</h2>
|
||||
<button class="btn small primary" id="conv-new">+ New</button>
|
||||
</div>
|
||||
<input type="search" id="conv-search" class="mt" placeholder="Search messages…" aria-label="Search messages">
|
||||
<ul id="conv-list" class="conv-list"></ul>
|
||||
<ul id="search-results" class="conv-list" hidden></ul>
|
||||
</aside>
|
||||
<section class="chat-main card">
|
||||
<div class="spread">
|
||||
<h2 id="chat-title">Select a conversation</h2>
|
||||
<span class="muted" id="typing-indicator" aria-live="polite"></span>
|
||||
<span style="display:flex;align-items:center;gap:10px">
|
||||
<span class="muted" id="typing-indicator" aria-live="polite"></span>
|
||||
<button class="btn small" id="chat-members" type="button" hidden>Manage members</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="chat-messages" class="chat-messages" aria-live="polite"></div>
|
||||
<form id="chat-form" hidden>
|
||||
@@ -66,6 +71,21 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="members-dialog">
|
||||
<div class="stack" style="min-width:420px">
|
||||
<div class="spread">
|
||||
<h2 style="margin:0">Group members</h2>
|
||||
<button class="btn small" type="button" id="mm-close">Close</button>
|
||||
</div>
|
||||
<ul id="mm-list" class="conv-list"></ul>
|
||||
<div class="field" id="mm-add-wrap">
|
||||
<label for="mm-search">Add someone</label>
|
||||
<input type="search" id="mm-search" placeholder="Search people…">
|
||||
<div id="mm-results" role="listbox" aria-label="Search results"></div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="lightbox" class="lightbox">
|
||||
<img id="lightbox-img" alt="">
|
||||
</dialog>
|
||||
|
||||
@@ -58,7 +58,18 @@
|
||||
<label for="p-theme">Theme</label>
|
||||
<select id="p-theme">
|
||||
<option value="light" {{if eq .User.Settings.Theme "light"}}selected{{end}}>Light (Y2K paper)</option>
|
||||
<option value="dark" {{if eq .User.Settings.Theme "dark"}}selected{{end}}>Dark</option>
|
||||
<option value="dark" {{if eq .User.Settings.Theme "dark"}}selected{{end}}>Dark (Y2K ink)</option>
|
||||
<option value="neon" {{if eq .User.Settings.Theme "neon"}}selected{{end}}>Neon (cyber gradient)</option>
|
||||
<option value="terminal" {{if eq .User.Settings.Theme "terminal"}}selected{{end}}>Terminal (green CRT)</option>
|
||||
<option value="blueprint" {{if eq .User.Settings.Theme "blueprint"}}selected{{end}}>Blueprint (graph paper)</option>
|
||||
<option value="sunset" {{if eq .User.Settings.Theme "sunset"}}selected{{end}}>Sunset (vaporwave)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="p-nav">Navigation</label>
|
||||
<select id="p-nav">
|
||||
<option value="side" {{if ne .User.Settings.NavLayout "top"}}selected{{end}}>Sidebar (left)</option>
|
||||
<option value="top" {{if eq .User.Settings.NavLayout "top"}}selected{{end}}>Top bar</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{{define "content"}}
|
||||
<div class="error-box" id="error" role="alert"></div>
|
||||
<div id="profile-root" data-user-id="{{.Data.UserID}}"></div>
|
||||
{{end}}
|
||||
@@ -1,7 +1,7 @@
|
||||
{{define "content"}}
|
||||
<header class="masthead">
|
||||
<p class="masthead-kicker">Consulting Work Exchange</p>
|
||||
<p class="masthead-title">◈ Bounty Board</p>
|
||||
<p class="masthead-title">◈ {{.Brand}}</p>
|
||||
<p class="masthead-rule">❦</p>
|
||||
</header>
|
||||
<div class="card">
|
||||
|
||||
Reference in New Issue
Block a user