Compare commits

...

18 Commits

Author SHA1 Message Date
Your Name 097d46886d fix(jira): migrate to /rest/api/3/search/jql (legacy /search removed)
Atlassian removed GET /rest/api/3/search (HTTP 410, CHANGE-2046), which
broke ticket sync for Jira Cloud customers. Migrate the connector's
search() to POST /rest/api/3/search/jql with cursor pagination
(nextPageToken/isLast) instead of the legacy startAt/total scheme.

Verified end-to-end against a live Jira Cloud instance: TestConnection,
search, FetchUpdated and ListAssignedKeys all succeed and parse issues
correctly. Spec §5.3 updated to document the new endpoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 17:35:23 +02:00
etalon 0dcc0bc823 fix: align login buttons; move helper links to their own line
Log in / Continue with SSO now sit in a wrapping button row; "Forgot password? ·
Create an account" moves to a left-aligned line below instead of being crammed
beside the button (where "Create an account" wrapped and misaligned).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:53:15 +02:00
etalon c4f666209c chore: gitignore .env backups/.env.local
Keep stray .env.bak*/.env.local files (e.g. created while wiring OIDC) out of
version control so credentials are never committed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:49:56 +02:00
etalon 9a5e302a26 feat: public profile page + richer hover card with "See full profile"
- New /users/{id} page renders a user's full profile from the card endpoint:
  avatar, roles, full bio, contact (location/phone), links and custom detail
  fields (internal extra keys hidden; link schemes sanitized).
- The hover user-card now shows a longer bio, phone, up to 3 links, and a
  "See full profile →" button linking to /users/{id}.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:40:16 +02:00
etalon 88fa5d19c8 fix: long brand name no longer overflows the sidebar
In sidebar mode the masthead brand was white-space:nowrap and overflowed the
214px rail for longer names. Constrain it to the rail width and let it wrap;
bump the link list's top padding so a two-line brand doesn't overlap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:36:16 +02:00
etalon c65aeb4bac feat: add Source button to the task detail view
Tasks imported from a ticketing system now show a "Source ↗" button in the task
detail actions (same affordance as the atomization board), linking to the
upstream ticket. The small inline key link is kept too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:34:11 +02:00
etalon 46e2099542 fix: apply the BrandingName setting to the masthead and page title
The brandingName admin setting was saved but never used — layout/login/register
hardcoded "Bounty Board". render() now passes the configured brand into every
page, and the top-nav masthead, <title>, and login/register headers use it.
(The "Bounty Board" board-feature labels are left as-is.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:31:23 +02:00
etalon 35168b9b83 fix: sanitizer double-escaped entities (&nbsp;/&amp;); dot-prefix volumes dir
- chat.SanitizeHTML/SanitizePlain decoded-then-escape text runs, so HTML
  entities the browser emits (e.g. &nbsp; for the space after a mention chip,
  or &amp; for a typed "&") no longer render as literal "&nbsp;"/"&amp;".
  Re-escaping keeps output XSS-safe. Adds regression tests.
- Rename ./volumes -> ./.volumes so `go build/vet/test ./...` skip the mongo
  data dir (700-perm files); Makefile lint now gofmt's tracked files only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:26:32 +02:00
etalon 0676f53280 chore: bind-mount docker volumes under the repo (./volumes), gitignore them
Replace the named volumes mongo-data and work-data with bind mounts to
./volumes/mongo and ./volumes/work so all runtime state lives under the repo
tree. Existing data was migrated; the old named volumes were removed. The
${HOME}/.claude host-credential mounts are intentionally left as-is (must not
live in the repo). Added /volumes/ to .gitignore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:15:46 +02:00
etalon f54c557e70 feat: Archive tab — role-scoped list of archived tasks with search
New /archive page (nav link for all roles) backed by GET /api/v1/archive:
- admins see every archived task;
- consultants see archived tasks for customers they're assigned to;
- developers see archived tasks they were assigned to, claimed, or acted on.
Supports full-text ?q= search; cards link to the task detail view.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:12:52 +02:00
etalon b232b4b3d3 feat: structured @-mentions (notify+clickable+hover), distinct links, Ctrl-rebalance sliders
- Mentions now insert a span carrying the user id (handles names with spaces):
  the sanitizer permits <span class="mention" data-user-card="ULID">, comment
  and chat notifications resolve by id and only fire for users with access,
  and mentions render as clickable chips with the shared hover user-card.
- Messages composer inserts an atomic, non-editable mention chip so the
  trailing space no longer collapses while typing.
- Links inside chat messages and task comments are underlined/accented so they
  read as clickable.
- Atomization: hold Ctrl/Cmd while dragging an effort slider to rebalance the
  sibling coefficients proportionally (sum stays ~1.00); added a hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:09:17 +02:00
etalon 70cb664246 feat: @-mention autocomplete; consultant bounty-board access; clickable atomization tasks
- @-mentions: typing "@" in task comments, the messages composer and the chat
  widget now opens a user picker (new shared mention.js) that inserts "@Name ".
  Backed by the existing /api/v1/users search; menus de-dupe per field.
- Consultants can now open the bounty board (read-only): nav link + page/API
  access extended to consultants, board visibility resolves their assigned
  customers, and cards show "Open" instead of claim actions for non-developers.
- Atomization board task titles (roots and subtasks) link to /tasks/{id} so
  consultants can reach the detail view (comments, assignment, timeline).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:54:44 +02:00
etalon b4e919502f feat: chat scroll/pagination, message search; fix members dialog growth
- Messages: the message list now scrolls inside a fixed-height pane instead of
  growing the page; new messages autoscroll only when already at the bottom,
  and scrolling near the top loads older history (40 at a time) with the
  position preserved.
- Add a message search box in the conversations sidebar backed by a new
  GET /api/v1/messages/search endpoint (searches the caller's conversations,
  returns snippets); clicking a result opens that conversation.
- Manage-members dialog no longer grows horizontally: results are stacked in a
  fixed-width, scrollable list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:43:11 +02:00
etalon df08682bed fix: audit log pagination + full-width table, board card button alignment, light sidebar dither
- Audit log: backend only returns a next cursor when the page is full, so the
  "Load more" button no longer needs two clicks (and hides at the last page).
  Table now spans the full screen width and long detail JSON wraps instead of
  overflowing.
- Bounty board: pin each card's action row to the bottom so "Request
  assignment" lines up across equal-height cards.
- Light theme: restore the Y2K dither texture on the sidebar nav panel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:36:32 +02:00
etalon 420f045033 style: darker/sparser dither on dark themes; CRT scanlines for terminal
The bright dither dots created visual noise behind sidebar menu items on the
dark themes. Dark/neon/blueprint/sunset now use a low-contrast dot a touch
above their background on a sparser 8px tile. Terminal swaps dots for faint
horizontal scanlines (1×3 tile) for an interlaced CRT look.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:23:22 +02:00
etalon 9bacb8092d fix: brighten nav menu link color for readability on dark themes
Sidebar/top-bar links used --muted, which was too dim against the dark
surfaces of the neon/dark/terminal themes. Mix 72% --text so menu items stay
legible everywhere while inactive links remain softer than headings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:19:37 +02:00
etalon e8beb7d4f3 feat: sidebar nav (default), group member management, per-customer ticketing identity mapping
- Navigation: new per-user setting (default "side") renders the page links as a
  left sidebar while keeping theme/bell/avatar/logout in the top-right; falls
  back to a top bar under 760px or when set to "top". Profile selector added.
- Group conversations: record a creatorId; the creator gets a "Manage members"
  button to add/remove participants (new GET/POST/DELETE members endpoints,
  creator-only). Existing groups backfilled.
- Customer ticketing: admin can map each assigned consultant to their username
  in that customer's ticketing system. Per-customer mapping takes precedence
  over the consultant's global ticketingIdentities during sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:05:33 +02:00
etalon 0d28f69320 feat: 5 dramatic extra themes (neon/terminal/blueprint/sunset) + board/review/layout polish
- Themes: add neon (cyber gradient), terminal (green CRT), blueprint (graph
  paper) and sunset (vaporwave) alongside the default Light (Y2K paper) and
  Dark. Theme toggle now cycles all; profile selector lists them; server
  validates against a shared whitelist.
- Bounty cards now show the project (customer) name.
- Remove the celebratory emoji from the empty Review Queue.
- Full-width h1 underline rule; My Tasks columns stack vertically for more
  room per task; card action buttons wrap/right-align so they never overflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 10:44:07 +02:00
42 changed files with 1462 additions and 145 deletions
+5
View File
@@ -3,3 +3,8 @@ bin/
backups/ backups/
*.test *.test
coverage.out coverage.out
# Docker volume data (bind-mounted under the repo; never committed)
/.volumes/
.env.bak*
.env.local
+1 -1
View File
@@ -25,7 +25,7 @@ test-contract:
$(GO) test -tags=contract -count=1 -timeout=8m ./internal/contract/ $(GO) test -tags=contract -count=1 -timeout=8m ./internal/contract/
lint: 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 echo "gofmt needed on:"; echo "$$unformatted"; exit 1; fi
$(GO) vet ./... $(GO) vet ./...
+3 -6
View File
@@ -13,7 +13,8 @@ services:
# Mongo is reachable only on the compose network; never published to the # Mongo is reachable only on the compose network; never published to the
# host (§12). # host (§12).
volumes: volumes:
- mongo-data:/data/db # data lives in-repo (gitignored, dot-prefixed so go tooling skips it)
- ./.volumes/mongo:/data/db
healthcheck: healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s interval: 10s
@@ -82,7 +83,7 @@ services:
volumes: volumes:
- ${HOME}/.claude:/home/node/.claude - ${HOME}/.claude:/home/node/.claude
- ${HOME}/.claude.json:/home/node/.claude.json - ${HOME}/.claude.json:/home/node/.claude.json
- work-data:/work - ./.volumes/work:/work
ports: ports:
- "127.0.0.1:8091:8091" - "127.0.0.1:8091:8091"
extra_hosts: extra_hosts:
@@ -93,7 +94,3 @@ services:
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 5s start_period: 5s
volumes:
mongo-data:
work-data:
+82 -4
View File
@@ -31,10 +31,10 @@ func SanitizeHTML(in string) string {
if c != '<' { if c != '<' {
j := strings.IndexByte(in[i:], '<') j := strings.IndexByte(in[i:], '<')
if j < 0 { if j < 0 {
out.WriteString(html.EscapeString(in[i:])) out.WriteString(escapeText(in[i:]))
break break
} }
out.WriteString(html.EscapeString(in[i : i+j])) out.WriteString(escapeText(in[i : i+j]))
i += j i += j
continue continue
} }
@@ -53,6 +53,31 @@ func SanitizeHTML(in string) string {
name, attrs := splitTag(raw) name, attrs := splitTag(raw)
name = strings.ToLower(name) 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] { if !allowedTags[name] {
continue // drop the tag entirely, keep surrounding text continue // drop the tag entirely, keep surrounding text
} }
@@ -93,6 +118,15 @@ func SanitizeHTML(in string) string {
return out.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. &nbsp; for spaces around inline
// elements, or &amp; for a typed "&" — don't get double-escaped into visible
// "&nbsp;"/"&amp;" 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. // splitTag separates the tag name from its raw attribute string.
func splitTag(raw string) (string, string) { func splitTag(raw string) (string, string) {
for i := 0; i < len(raw); i++ { for i := 0; i < len(raw); i++ {
@@ -104,6 +138,50 @@ func splitTag(raw string) (string, string) {
return raw, "" 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. // safeHref extracts href and accepts only http, https, or mailto schemes.
func safeHref(attrs string) string { func safeHref(attrs string) string {
lower := strings.ToLower(attrs) lower := strings.ToLower(attrs)
@@ -150,10 +228,10 @@ func SanitizePlain(in string) string {
if in[i] != '<' { if in[i] != '<' {
j := strings.IndexByte(in[i:], '<') j := strings.IndexByte(in[i:], '<')
if j < 0 { if j < 0 {
out.WriteString(html.EscapeString(in[i:])) out.WriteString(escapeText(in[i:]))
break break
} }
out.WriteString(html.EscapeString(in[i : i+j])) out.WriteString(escapeText(in[i : i+j]))
i += j i += j
continue continue
} }
+4
View File
@@ -12,6 +12,10 @@ func TestSanitizeHTML(t *testing.T) {
want string want string
}{ }{
{"plain text escaped", `hello <world> & "friends"`, "hello &amp; &#34;friends&#34;"}, {"plain text escaped", `hello <world> & "friends"`, "hello &amp; &#34;friends&#34;"},
{"existing entity not double-escaped", `a &amp; b &lt;ok&gt;`, "a &amp; b &lt;ok&gt;"},
{"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>"}, {"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>"}, {"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>"}, {"code and pre", "<pre><code>x := 1</code></pre>", "<pre><code>x := 1</code></pre>"},
+5
View File
@@ -28,6 +28,11 @@ type Ticketing struct {
CredentialsEnc string `bson:"credentialsEnc" json:"-"` CredentialsEnc string `bson:"credentialsEnc" json:"-"`
ProjectKey string `bson:"projectKey" json:"projectKey"` ProjectKey string `bson:"projectKey" json:"projectKey"`
PollIntervalSec int `bson:"pollIntervalSec" json:"pollIntervalSec"` PollIntervalSec int `bson:"pollIntervalSec" json:"pollIntervalSec"`
// 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"` LastSyncAt time.Time `bson:"lastSyncAt" json:"lastSyncAt"`
LastSyncStatus string `bson:"lastSyncStatus" json:"lastSyncStatus"` // "", ok, error LastSyncStatus string `bson:"lastSyncStatus" json:"lastSyncStatus"` // "", ok, error
LastSyncError string `bson:"lastSyncError" json:"lastSyncError"` LastSyncError string `bson:"lastSyncError" json:"lastSyncError"`
+1
View File
@@ -54,6 +54,7 @@ type NotificationPrefs struct {
type UserSettings struct { type UserSettings struct {
Theme string `bson:"theme" json:"theme"` Theme string `bson:"theme" json:"theme"`
NavLayout string `bson:"navLayout" json:"navLayout"` // "side" (default) | "top"
Notifications NotificationPrefs `bson:"notifications" json:"notifications"` Notifications NotificationPrefs `bson:"notifications" json:"notifications"`
LeaderboardOptOut bool `bson:"leaderboardOptOut" json:"leaderboardOptOut"` LeaderboardOptOut bool `bson:"leaderboardOptOut" json:"leaderboardOptOut"`
} }
+8 -2
View File
@@ -102,14 +102,20 @@ func (s *Server) handleAdminPatchSettings(w http.ResponseWriter, r *http.Request
func (s *Server) handleAdminAuditLog(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminAuditLog(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query() 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"), 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 { if err != nil {
s.internalError(w, r, "list audit", err) s.internalError(w, r, "list audit", err)
return 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 := "" next := ""
if len(entries) > 0 { if len(entries) == limit {
next = entries[len(entries)-1].ID next = entries[len(entries)-1].ID
} }
writeJSON(w, http.StatusOK, map[string]any{"entries": entries, "nextCursor": next}) writeJSON(w, http.StatusOK, map[string]any{"entries": entries, "nextCursor": next})
+21
View File
@@ -26,6 +26,7 @@ type customerPayload struct {
Credentials *syncpkg.Credentials `json:"credentials"` Credentials *syncpkg.Credentials `json:"credentials"`
PollInterval *int `json:"pollIntervalSec"` PollInterval *int `json:"pollIntervalSec"`
ConsultantIDs *[]string `json:"consultantIds"` ConsultantIDs *[]string `json:"consultantIds"`
ConsultantIdentities *map[string]string `json:"consultantIdentities"`
DefaultBudget *float64 `json:"defaultBudget"` DefaultBudget *float64 `json:"defaultBudget"`
Archived *bool `json:"archived"` Archived *bool `json:"archived"`
Version *int64 `json:"version"` Version *int64 `json:"version"`
@@ -65,6 +66,7 @@ func customerJSON(c *domain.Customer) map[string]any {
"lastSyncAt": c.Ticketing.LastSyncAt, "lastSyncStatus": c.Ticketing.LastSyncStatus, "lastSyncAt": c.Ticketing.LastSyncAt, "lastSyncStatus": c.Ticketing.LastSyncStatus,
"lastSyncError": c.Ticketing.LastSyncError, "lastSyncError": c.Ticketing.LastSyncError,
"hasCredentials": c.Ticketing.CredentialsEnc != "", "hasCredentials": c.Ticketing.CredentialsEnc != "",
"consultantIdentities": c.Ticketing.ConsultantIdentities,
}, },
"consultantIds": c.ConsultantIDs, "defaultBudget": c.DefaultBudget, "consultantIds": c.ConsultantIDs, "defaultBudget": c.DefaultBudget,
"archived": c.Archived, "createdAt": c.CreatedAt, "updatedAt": c.UpdatedAt, "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)}) 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) { func (s *Server) validateConsultants(ctx context.Context, ids []string) (string, bool) {
for _, id := range ids { for _, id := range ids {
u, err := s.store.UserByID(ctx, id) 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 c.ConsultantIDs = *req.ConsultantIDs
} }
if req.ConsultantIdentities != nil {
c.Ticketing.ConsultantIdentities = cleanIdentities(*req.ConsultantIdentities)
}
creds := syncpkg.Credentials{} creds := syncpkg.Credentials{}
if req.Credentials != nil { if req.Credentials != nil {
creds = *req.Credentials creds = *req.Credentials
@@ -236,6 +253,10 @@ func (s *Server) handleAdminPatchCustomer(w http.ResponseWriter, r *http.Request
set["consultantIds"] = *req.ConsultantIDs set["consultantIds"] = *req.ConsultantIDs
diff["consultantIds"] = *req.ConsultantIDs diff["consultantIds"] = *req.ConsultantIDs
} }
if req.ConsultantIdentities != nil {
set["ticketing.consultantIdentities"] = cleanIdentities(*req.ConsultantIdentities)
diff["consultantIdentities"] = "updated"
}
if req.DefaultBudget != nil { if req.DefaultBudget != nil {
set["defaultBudget"] = *req.DefaultBudget set["defaultBudget"] = *req.DefaultBudget
diff["defaultBudget"] = *req.DefaultBudget diff["defaultBudget"] = *req.DefaultBudget
+48 -33
View File
@@ -19,7 +19,9 @@ import (
func (s *Server) routesBoard(mux *http.ServeMux) { func (s *Server) routesBoard(mux *http.ServeMux) {
dev := func(h http.HandlerFunc) http.Handler { return s.authedRole("developer", h) } 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("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", dev(s.handleClaim))
mux.Handle("POST /api/v1/tasks/{id}/claim/withdraw", dev(s.handleWithdrawClaim)) 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 // assigned to a customer manage its tasks, so a task published by any of
// them belongs to the same board. // them belongs to the same board.
func (s *Server) boardCustomers(r *http.Request) ([]domain.Customer, error) { func (s *Server) boardCustomers(r *http.Request) ([]domain.Customer, error) {
consultants, err := s.store.PoolConsultantIDs(r.Context(), CurrentUser(r.Context()).ID) me := CurrentUser(r.Context())
if err != nil {
return nil, err
}
seen := map[string]bool{} seen := map[string]bool{}
out := []domain.Customer{} out := []domain.Customer{}
for _, cid := range consultants { add := func(customers []domain.Customer) {
customers, err := s.store.ListCustomers(r.Context(), cid, false)
if err != nil {
return nil, err
}
for _, c := range customers { for _, c := range customers {
if !seen[c.ID] { if !seen[c.ID] {
seen[c.ID] = true 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 return out, nil
} }
@@ -319,7 +336,9 @@ func (s *Server) handleAbandonTask(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) 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) { func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
@@ -375,45 +394,41 @@ func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, map[string]any{"comment": comment}) writeJSON(w, http.StatusCreated, map[string]any{"comment": comment})
} }
// notifyMentions resolves @name / @email tokens against task participants // notifyMentions notifies users referenced by mention spans in the comment
// (§11.3). // 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) { func (s *Server) notifyMentions(r *http.Request, t *domain.Task, author *domain.User, body string) {
plain := chat.SanitizePlain(body) matches := mentionUIDRe.FindAllStringSubmatch(body, 20)
matches := mentionRe.FindAllStringSubmatch(plain, 10)
if len(matches) == 0 { if len(matches) == 0 {
return return
} }
// candidate participants ctx := r.Context()
ids := map[string]bool{t.ConsultantID: true} // 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 != "" { if t.Assignee != nil && t.Assignee.UserID != "" {
ids[t.Assignee.UserID] = true access[t.Assignee.UserID] = true
} }
for _, cr := range t.ClaimRequests { for _, cr := range t.ClaimRequests {
ids[cr.DeveloperID] = true access[cr.DeveloperID] = true
} }
for _, c := range t.Comments { for _, c := range t.Comments {
ids[c.AuthorID] = true access[c.AuthorID] = true
} }
notified := map[string]bool{} notified := map[string]bool{}
for _, m := range matches { for _, m := range matches {
token := strings.ToLower(m[1]) uid := m[1]
for id := range ids { if uid == author.ID || notified[uid] || !access[uid] {
if id == author.ID || notified[id] {
continue continue
} }
u, err := s.store.UserByID(r.Context(), id) notified[uid] = true
if err != nil { s.notifyUser(ctx, uid, "mention",
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) author.Name+" mentioned you", "On task: "+t.Title, "/tasks/"+t.ID)
} }
} }
}
}
func (s *Server) handleLogTime(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLogTime(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
+214 -8
View File
@@ -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("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}/messages", s.authed(s.handleSendMessage))
mux.Handle("POST /api/v1/conversations/{id}/read", s.authed(s.handleMarkRead)) 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("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/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. // conversation loads + authorizes participant access.
@@ -81,6 +85,7 @@ func (s *Server) handleListConversations(w http.ResponseWriter, r *http.Request)
out[i] = map[string]any{ out[i] = map[string]any{
"id": c.ID, "kind": c.Kind, "title": title, "id": c.ID, "kind": c.Kind, "title": title,
"customerId": c.CustomerID, "participantIds": c.ParticipantIDs, "customerId": c.CustomerID, "participantIds": c.ParticipantIDs,
"creatorId": c.CreatorID,
"lastMessageAt": c.LastMessageAt, "unread": unread[c.ID], "lastMessageAt": c.LastMessageAt, "unread": unread[c.ID],
} }
} }
@@ -135,6 +140,7 @@ func (s *Server) handleCreateConversation(w http.ResponseWriter, r *http.Request
conv := &store.Conversation{ conv := &store.Conversation{
Kind: req.Kind, Kind: req.Kind,
Title: strings.TrimSpace(req.Title), Title: strings.TrimSpace(req.Title),
CreatorID: me.ID,
ParticipantIDs: participants, ParticipantIDs: participants,
} }
if req.Kind == "project" { if req.Kind == "project" {
@@ -226,20 +232,22 @@ func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) {
if s.sendTo != nil { if s.sendTo != nil {
s.sendTo(conv.ParticipantIDs, "chat", "message", msg) s.sendTo(conv.ParticipantIDs, "chat", "message", msg)
} }
// Notify mentioned users who are participants (i.e. have access §11.1).
plain := chat.SanitizePlain(body) plain := chat.SanitizePlain(body)
participants := map[string]bool{}
for _, pid := range conv.ParticipantIDs { 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 continue
} }
if u, err := s.store.UserByID(r.Context(), pid); err == nil { notified[uid] = true
first := strings.ToLower(strings.SplitN(u.Name, " ", 2)[0]) s.notifyUser(r.Context(), uid, "mention",
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) me.Name+" mentioned you in a chat", truncateStr(plain, 120), "/messages?c="+conv.ID)
} }
}
}
writeJSON(w, http.StatusCreated, map[string]any{"message": msg}) 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}) 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 // handleUploadFile is the generic upload endpoint (POST /files, §6) used by
// chat; scope=chat unless the caller passes scope=task (consultants). // chat; scope=chat unless the caller passes scope=task (consultants).
func (s *Server) handleUploadFile(w http.ResponseWriter, r *http.Request) { 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}) 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). // HandleInboundWS relays ephemeral client events (typing indicator).
func (s *Server) HandleInboundWS(userID string, msg ws.Message) { func (s *Server) HandleInboundWS(userID string, msg ws.Message) {
if msg.Channel != "chat" || msg.Event != "typing" { if msg.Channel != "chat" || msg.Event != "typing" {
+17 -2
View File
@@ -15,6 +15,12 @@ import (
"bountyboard/internal/store" "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) { func (s *Server) routesProfile(mux *http.ServeMux) {
mux.Handle("GET /api/v1/profile", s.requireAuth(http.HandlerFunc(s.handleGetProfile))) mux.Handle("GET /api/v1/profile", s.requireAuth(http.HandlerFunc(s.handleGetProfile)))
mux.Handle("PATCH /api/v1/profile", s.authed(s.handlePatchProfile)) 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"` Extra *map[string]any `json:"extra"`
Settings *struct { Settings *struct {
Theme *string `json:"theme"` Theme *string `json:"theme"`
NavLayout *string `json:"navLayout"`
Notifications *domain.NotificationPrefs `json:"notifications"` Notifications *domain.NotificationPrefs `json:"notifications"`
LeaderboardOptOut *bool `json:"leaderboardOptOut"` LeaderboardOptOut *bool `json:"leaderboardOptOut"`
} `json:"settings"` } `json:"settings"`
@@ -71,12 +78,20 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
if req.Settings != nil { if req.Settings != nil {
if req.Settings.Theme != nil { if req.Settings.Theme != nil {
theme := *req.Settings.Theme theme := *req.Settings.Theme
if theme != "light" && theme != "dark" { if !validThemes[theme] {
writeError(w, http.StatusBadRequest, "invalid_theme", "theme must be light or dark") writeError(w, http.StatusBadRequest, "invalid_theme", "unknown theme")
return return
} }
set["settings.theme"] = theme 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 { if req.Settings.Notifications != nil {
set["settings.notifications"] = *req.Settings.Notifications set["settings.notifications"] = *req.Settings.Notifications
} }
+1 -1
View File
@@ -108,7 +108,7 @@ func TestProfilePatchAndThemePersistence(t *testing.T) {
// invalid theme rejected // invalid theme rejected
resp = patchJSON(t, c, ts.URL+"/api/v1/profile", map[string]any{ 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) }, csrf)
if resp.StatusCode != http.StatusBadRequest { if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("invalid theme: %d", resp.StatusCode) t.Fatalf("invalid theme: %d", resp.StatusCode)
+57
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"bountyboard/internal/atomize" "bountyboard/internal/atomize"
"bountyboard/internal/domain" "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/publish", s.authedRole("consultant", s.handlePublishBulk))
mux.Handle("POST /api/v1/tasks/{id}/archive", s.authed(s.handleArchiveTask)) 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("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/tasks/{id}", s.requireAuth(http.HandlerFunc(s.handleTaskDetail)))
mux.Handle("GET /api/v1/service-health", s.requireAuth(http.HandlerFunc(s.handleServiceHealth))) 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 // consultantTask loads a task and authorizes the current user: admins and
// every consultant assigned to the task's customer may manage it (§4.4). // 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) { func (s *Server) consultantTask(w http.ResponseWriter, r *http.Request, id string) (*domain.Task, *domain.Customer, bool) {
+27 -1
View File
@@ -22,6 +22,8 @@ type pageData struct {
Active string // nav highlight key Active string // nav highlight key
User *domain.User User *domain.User
DefaultTheme string DefaultTheme string
NavLayout string
Brand string
Narrow bool Narrow bool
OIDCEnabled bool OIDCEnabled bool
Error string 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 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") w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "layout", data); err != nil { if err := t.ExecuteTemplate(w, "layout", data); err != nil {
s.log.Error("execute template", "page", page, "err", err) 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 } isDev := func(u *domain.User) bool { return u.Roles.Developer }
isCons := func(u *domain.User) bool { return u.Roles.Consultant } 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("/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/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("/consultant/pool", "pool.html", "Developer Pool", "pool", "/static/js/pool.js", isCons)
rolePage("/notifications", "notifications.html", "Notifications", "", "/static/js/notifications-page.js", nil) 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) { mux.HandleFunc("GET /tasks/{id}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
s.render(w, r, "task.html", &pageData{ 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("/messages", "messages.html", "Messages", "messages", "/static/js/messages.js", nil)
rolePage("/metrics", "metrics.html", "Metrics", "metrics", "/static/js/metrics.js", nil) rolePage("/metrics", "metrics.html", "Metrics", "metrics", "/static/js/metrics.js", nil)
} }
+23
View File
@@ -19,6 +19,7 @@ type Conversation struct {
Kind string `bson:"kind" json:"kind"` // dm | group | project Kind string `bson:"kind" json:"kind"` // dm | group | project
CustomerID string `bson:"customerId,omitempty" json:"customerId,omitempty"` CustomerID string `bson:"customerId,omitempty" json:"customerId,omitempty"`
Title string `bson:"title,omitempty" json:"title,omitempty"` Title string `bson:"title,omitempty" json:"title,omitempty"`
CreatorID string `bson:"creatorId,omitempty" json:"creatorId,omitempty"`
ParticipantIDs []string `bson:"participantIds" json:"participantIds"` ParticipantIDs []string `bson:"participantIds" json:"participantIds"`
LastMessageAt time.Time `bson:"lastMessageAt" json:"lastMessageAt"` LastMessageAt time.Time `bson:"lastMessageAt" json:"lastMessageAt"`
CreatedAt time.Time `bson:"createdAt" json:"createdAt"` CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
@@ -104,6 +105,28 @@ func (s *Store) ConversationByID(ctx context.Context, id string) (*Conversation,
return &c, nil 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) { func (s *Store) ListConversations(ctx context.Context, userID string) ([]Conversation, error) {
cur, err := s.DB.Collection("conversations").Find(ctx, cur, err := s.DB.Collection("conversations").Find(ctx,
bson.M{"participantIds": userID}, bson.M{"participantIds": userID},
+26 -8
View File
@@ -2,9 +2,9 @@ package sync
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
) )
@@ -47,23 +47,41 @@ type jiraIssue struct {
} `json:"fields"` } `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) { 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 var all []jiraIssue
for startAt := 0; ; { nextPageToken := ""
for {
reqBody := map[string]any{
"jql": jql,
"fields": fieldList,
"maxResults": 100,
}
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 { var page struct {
Issues []jiraIssue `json:"issues"` Issues []jiraIssue `json:"issues"`
Total int `json:"total"` NextPageToken string `json:"nextPageToken"`
IsLast bool `json:"isLast"`
} }
u := fmt.Sprintf("%s/rest/api/3/search?jql=%s&fields=%s&maxResults=100&startAt=%d", if err := doJSON(ctx, j.Authorize, http.MethodPost, u, body, &page); err != nil {
j.baseURL, url.QueryEscape(jql), url.QueryEscape(fields), startAt)
if err := getJSON(ctx, j.Authorize, u, &page); err != nil {
return nil, fmt.Errorf("jira search: %w", err) return nil, fmt.Errorf("jira search: %w", err)
} }
all = append(all, page.Issues...) all = append(all, page.Issues...)
startAt += len(page.Issues) if page.IsLast || page.NextPageToken == "" || len(page.Issues) == 0 {
if len(page.Issues) == 0 || startAt >= page.Total {
return all, nil return all, nil
} }
nextPageToken = page.NextPageToken
} }
} }
+6 -1
View File
@@ -257,7 +257,12 @@ func (m *Manager) SyncCustomer(ctx context.Context, c *domain.Customer) error {
if err != nil { if err != nil {
continue // consultant deleted; reconcile will catch up 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 == "" { if identity == "" {
continue // §5.3: consultant has no linked account in this system continue // §5.3: consultant has no linked account in this system
} }
+1 -1
View File
@@ -441,7 +441,7 @@ account** in that system, updated since `lastSyncAt 5 min` (overlap window):
| System | Query mechanism | | 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) | | 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) | | YouTrack | `GET /api/issues?query=assignee: <login> project: <key> updated: <ts> .. *` (Bearer permanent token) |
+257 -8
View File
@@ -56,8 +56,116 @@
:root[data-theme=dark] { :root[data-theme=dark] {
--ink-shadow: 3px 3px 0 rgba(0, 0, 0, 0.55); --ink-shadow: 3px 3px 0 rgba(0, 0, 0, 0.55);
--ink-shadow-lift: 5px 5px 0 rgba(0, 0, 0, 0.65); --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: 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='2' height='2'%3E%3Crect width='1' height='1' x='0' y='0' fill='%23d9a548'/%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 ---- */ /* ---- base ---- */
@@ -100,7 +208,7 @@ h1 { font-size: 2.1rem; }
h1::after { h1::after {
content: ""; content: "";
display: block; display: block;
width: 88px; height: 8px; width: 100%; height: 8px;
margin-top: 10px; margin-top: 10px;
background-image: var(--dither-dense); background-image: var(--dither-dense);
border-bottom: 2px solid var(--accent); 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 .brand:hover { text-decoration: none; }
.topnav .links { display: flex; gap: 2px; flex: 1; flex-wrap: wrap; } .topnav .links { display: flex; gap: 2px; flex: 1; flex-wrap: wrap; }
.topnav .links a { .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; padding: 7px 11px;
border-radius: var(--radius); border-radius: var(--radius);
font-weight: 500; 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; } .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 ---- */ /* ---- cards: printed stock with hard Y2K shadows ---- */
.card { .card {
background: var(--surface); 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 { display: grid; gap: 18px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.grid > .card { display: flex; flex-direction: column; } /* equal-height rows */ .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: filter rows boxed and baseline-aligned ---- */
.toolbar { .toolbar {
@@ -391,6 +566,7 @@ table.list tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surfa
.muted { color: var(--muted); } .muted { color: var(--muted); }
.spread { display: flex; justify-content: space-between; align-items: center; gap: 16px; } .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; } .stack > * + * { margin-top: 16px; }
.mt { 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 ---- */
.chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 18px; min-height: 70vh; } .chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 18px; height: calc(100vh - 150px); }
.chat-sidebar { overflow: auto; } .chat-sidebar { overflow: auto; min-height: 0; }
.conv-list { list-style: none; margin: 8px 0 0; padding: 0; } .conv-list { list-style: none; margin: 8px 0 0; padding: 0; }
.conv-list li { border-bottom: var(--hairline); } .conv-list li { border-bottom: var(--hairline); }
.conv-list li.active .conv-item { .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); border-radius: var(--radius);
} }
.conv-item:hover { background: var(--surface2); } .conv-item:hover { background: var(--surface2); }
.chat-main { display: flex; flex-direction: column; } .chat-main { display: flex; flex-direction: column; min-height: 0; }
.chat-messages { flex: 1; overflow: auto; padding: 8px 0; min-height: 320px; } /* 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 { .chat-msg {
max-width: 75%; margin: 10px 0; padding: 9px 13px; max-width: 75%; margin: 10px 0; padding: 9px 13px;
background: var(--surface2); 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; letter-spacing: 0.16em;
color: var(--muted); 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 ---- */ /* ---- notification bell ---- */
.bell-badge { .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); 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 ---- */ /* ---- dialogs: paper slips ---- */
dialog { dialog {
background: var(--surface); color: var(--text); 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-results .nc-empty { color: var(--muted); padding: 10px; font-size: 0.88rem; }
#nc-selected { min-height: 30px; display: flex; gap: 6px; flex-wrap: wrap; } #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 ---- */ /* ---- floating messages bubble + mini panel ---- */
.chat-fab { .chat-fab {
position: fixed; bottom: 16px; right: 16px; z-index: 2147483645; position: fixed; bottom: 16px; right: 16px; z-index: 2147483645;
+38
View File
@@ -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) { function openCustomerDialog(customer) {
editingCustomer = customer || null; editingCustomer = customer || null;
document.getElementById('customer-dialog-title').textContent = document.getElementById('customer-dialog-title').textContent =
@@ -73,6 +103,7 @@ function openCustomerDialog(customer) {
[...sel.options].forEach((o) => { [...sel.options].forEach((o) => {
o.selected = customer ? customer.consultantIds.includes(o.value) : false; o.selected = customer ? customer.consultantIds.includes(o.value) : false;
}); });
renderIdentities();
document.getElementById('c-test-result').textContent = document.getElementById('c-test-result').textContent =
customer && customer.ticketing.hasCredentials ? 'Stored credentials are kept unless you enter new ones.' : ''; customer && customer.ticketing.hasCredentials ? 'Stored credentials are kept unless you enter new ones.' : '';
dlg.showModal(); dlg.showModal();
@@ -114,6 +145,11 @@ document.getElementById('customer-form').addEventListener('submit', async (e) =>
defaultBudget: parseFloat(val('c-budget')) || 0, defaultBudget: parseFloat(val('c-budget')) || 0,
consultantIds: [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value), 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); if (!editingCustomer || credsEntered(type)) body.credentials = credsFromForm(type);
try { try {
if (editingCustomer) { if (editingCustomer) {
@@ -326,6 +362,7 @@ async function loadAudit(reset = true) {
try { try {
const entity = encodeURIComponent(document.getElementById('audit-entity').value.trim()); const entity = encodeURIComponent(document.getElementById('audit-entity').value.trim());
if (reset) auditCursor = ''; 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}`); const res = await api('GET', `/api/v1/admin/audit-log?entityId=${entity}&cursor=${auditCursor}`);
auditCursor = res.nextCursor; auditCursor = res.nextCursor;
const rows = res.entries.map((e) => { const rows = res.entries.map((e) => {
@@ -344,6 +381,7 @@ async function loadAudit(reset = true) {
const tbody = document.querySelector('#audit-table tbody'); const tbody = document.querySelector('#audit-table tbody');
if (reset) tbody.replaceChildren(...rows); if (reset) tbody.replaceChildren(...rows);
else tbody.append(...rows); else tbody.append(...rows);
document.getElementById('audit-more').hidden = !auditCursor;
} catch (e) { showErr(e); } } catch (e) { showErr(e); }
} }
document.getElementById('audit-apply').addEventListener('click', () => loadAudit(true)); document.getElementById('audit-apply').addEventListener('click', () => loadAudit(true));
+44
View File
@@ -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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[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();
+58 -16
View File
@@ -11,6 +11,12 @@ let tasks = [];
let atomizerUp = true; let atomizerUp = true;
const selected = new Set(); 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) { function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ return String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
@@ -82,7 +88,7 @@ function renderRoot(root) {
? '<span class="badge" style="color:var(--err)" title="No longer returned by the upstream system">orphaned</span>' : ''; ? '<span class="badge" style="color:var(--err)" title="No longer returned by the upstream system">orphaned</span>' : '';
card.innerHTML = ` card.innerHTML = `
<div class="spread"> <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> <span class="muted">${esc(cust ? cust.name : '')} ${root.external ? '· ' + esc(root.external.key) : ''}</span>
</div> </div>
<p class="muted">${esc((root.description || '').slice(0, 240))}</p> <p class="muted">${esc((root.description || '').slice(0, 240))}</p>
@@ -115,14 +121,20 @@ function renderChildren(parent, kids, depth) {
wrap.className = 'stack mt'; wrap.className = 'stack mt';
const subdivided = kids.filter((k) => k.origin === 'subdivided'); 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'); const hasExtension = kids.some((k) => k.origin === 'extended');
if (subdivided.length) { const subSiblings = []; // {k, slider, coeffEl, bountyEl} for adjustable subdivided rows
const ind = document.createElement('p'); let ind = null;
const bad = !hasExtension && Math.abs(sum - 1) > 0.005; function updateSumIndicator() {
ind.innerHTML = `Coefficient sum: <strong style="color:${bad ? 'var(--err)' : 'var(--ok)'}">${sum.toFixed(2)}</strong>` + 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>' (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); wrap.appendChild(ind);
} }
@@ -135,7 +147,7 @@ function renderChildren(parent, kids, depth) {
<div class="spread"> <div class="spread">
<span> <span>
${k.status === 'atomized' ? `<input type="checkbox" data-sel aria-label="Select ${esc(k.title)} for publishing" ${selected.has(k.id) ? 'checked' : ''}>` : ''} ${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>
<span>bounty <strong data-bounty>${k.bounty}</strong></span> <span>bounty <strong data-bounty>${k.bounty}</strong></span>
</div> </div>
@@ -157,25 +169,54 @@ function renderChildren(parent, kids, depth) {
</span> </span>
</div>`; </div>`;
const slider = row.querySelector('input[type=range]'); 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; let sliderTimer = null;
const round2 = (n) => Math.round(n * 100) / 100;
slider.addEventListener('input', () => { slider.addEventListener('input', () => {
row.querySelector('[data-coeff]').textContent = parseFloat(slider.value).toFixed(2); const newVal = parseFloat(slider.value);
row.querySelector('[data-bounty]').textContent = (slider.value * k.budget).toFixed(2); 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); clearTimeout(sliderTimer);
sliderTimer = setTimeout(async () => { sliderTimer = setTimeout(async () => {
try { try {
const res = await api('PATCH', `/api/v1/tasks/${k.id}`, { for (const ch of changed) {
effortCoefficient: parseFloat(slider.value), version: k.version, const res = await api('PATCH', `/api/v1/tasks/${ch.k.id}`, {
effortCoefficient: ch.value, version: ch.k.version,
}); });
k.version = res.task.version; ch.k.version = res.task.version;
k.effortCoefficient = res.task.effortCoefficient; ch.k.effortCoefficient = res.task.effortCoefficient;
k.bounty = res.task.bounty; ch.k.bounty = res.task.bounty;
}
render(); render();
} catch (e) { } catch (e) {
if (e.status === 409) { toast('Task changed elsewhere — reloading.', 'err'); load(); } if (e.status === 409) { toast('Task changed elsewhere — reloading.', 'err'); load(); }
else toast(e.message, 'err'); else toast(e.message, 'err');
} }
}, 400); }, 450);
}); });
const sel = row.querySelector('[data-sel]'); const sel = row.querySelector('[data-sel]');
if (sel) { if (sel) {
@@ -200,6 +241,7 @@ function renderChildren(parent, kids, depth) {
const grand = childrenOf(k.id); const grand = childrenOf(k.id);
if (grand.length) wrap.appendChild(renderChildren(k, grand, depth + 1)); if (grand.length) wrap.appendChild(renderChildren(k, grand, depth + 1));
}); });
updateSumIndicator();
return wrap; return wrap;
} }
+8 -1
View File
@@ -4,7 +4,9 @@ import { subscribe, onPollFallback } from '/static/js/ws.js';
const grid = document.getElementById('board-grid'); const grid = document.getElementById('board-grid');
const errorBox = document.getElementById('error'); const errorBox = document.getElementById('error');
let meId = ''; let meId = '';
let isDeveloper = false;
let tasks = []; let tasks = [];
const customerNames = new Map();
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
@@ -25,6 +27,7 @@ async function load() {
const qs = new URLSearchParams(f).toString(); const qs = new URLSearchParams(f).toString();
const res = await api('GET', '/api/v1/board?' + qs); const res = await api('GET', '/api/v1/board?' + qs);
tasks = res.tasks; tasks = res.tasks;
(res.customers || []).forEach((c) => customerNames.set(c.id, c.name));
const sel = document.getElementById('f-customer'); const sel = document.getElementById('f-customer');
if (sel.options.length === 1) { if (sel.options.length === 1) {
res.customers.forEach((c) => sel.add(new Option(c.name, c.id))); 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 class="badge accent" style="font-size:1rem">◈ ${t.bounty}</span>
<span>${ageBadge(t)}</span> <span>${ageBadge(t)}</span>
</div> </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> <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">${esc((t.description || '').slice(0, 180))}</p>
<p class="muted">${(t.acceptanceCriteria || []).length} acceptance criteria</p> <p class="muted">${(t.acceptanceCriteria || []).length} acceptance criteria</p>
@@ -66,7 +70,9 @@ function renderCard(t) {
${myClaim ? '<span class="badge" style="color:var(--ok)">requested by you</span>' : ''} ${myClaim ? '<span class="badge" style="color:var(--ok)">requested by you</span>' : ''}
${others ? `<span class="badge">${others} other request(s)</span>` : ''} ${others ? `<span class="badge">${others} other request(s)</span>` : ''}
</span> </span>
${myClaim ${!isDeveloper
? '<a class="btn small" href="/tasks/' + t.id + '">Open</a>'
: myClaim
? '<button class="btn small" data-act="withdraw">Withdraw</button>' ? '<button class="btn small" data-act="withdraw">Withdraw</button>'
: '<button class="btn primary small" data-act="claim">Request assignment</button>'} : '<button class="btn primary small" data-act="claim">Request assignment</button>'}
</div>`; </div>`;
@@ -118,6 +124,7 @@ async function restoreFilters() {
try { try {
const me = await api('GET', '/api/v1/auth/me'); const me = await api('GET', '/api/v1/auth/me');
meId = me.user.id; meId = me.user.id;
isDeveloper = !!(me.user.roles && me.user.roles.developer);
const saved = me.user.extra && me.user.extra.savedBoardFilters; const saved = me.user.extra && me.user.extra.savedBoardFilters;
if (saved) { if (saved) {
const f = JSON.parse(saved); const f = JSON.parse(saved);
+6 -2
View File
@@ -3,6 +3,7 @@
// plain-text composer. Reuses the conversations API + live WS channel. // plain-text composer. Reuses the conversations API + live WS channel.
import { api } from '/static/js/api.js'; import { api } from '/static/js/api.js';
import { subscribe, onPollFallback } from '/static/js/ws.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') { if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages') {
let meId = ''; let meId = '';
@@ -112,14 +113,17 @@ if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages')
input.value = ''; input.value = '';
try { try {
await api('POST', `/api/v1/conversations/${current.id}/messages`, { 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; } } catch (e) { input.value = text; }
} }
panel.querySelector('#cw-send').addEventListener('click', send); 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(); } if (e.key === 'Enter') { e.preventDefault(); send(); }
}); });
attachMentions(cwInput);
fab.addEventListener('click', async () => { fab.addEventListener('click', async () => {
open = !open; open = !open;
+168
View File
@@ -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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
+135 -4
View File
@@ -1,5 +1,6 @@
import { api, toast } from '/static/js/api.js'; import { api, toast } from '/static/js/api.js';
import { subscribe, send as wsSend, onPollFallback } from '/static/js/ws.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 errorBox = document.getElementById('error');
const convList = document.getElementById('conv-list'); const convList = document.getElementById('conv-list');
@@ -61,7 +62,10 @@ function renderConvList() {
async function openConversation(c) { async function openConversation(c) {
current = c; current = c;
oldestCursor = ''; oldestCursor = '';
noMoreOlder = false;
document.getElementById('chat-title').textContent = c.title; 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; form.hidden = false;
msgHost.replaceChildren(); msgHost.replaceChildren();
renderConvList(); renderConvList();
@@ -72,19 +76,36 @@ async function openConversation(c) {
history.replaceState(null, '', '/messages?c=' + c.id); history.replaceState(null, '', '/messages?c=' + c.id);
} }
const MSG_PAGE = 40;
async function loadMessages(prepend) { async function loadMessages(prepend) {
const res = await api('GET', 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; const msgs = res.messages;
if (prepend && msgs.length < MSG_PAGE) noMoreOlder = true;
if (msgs.length) oldestCursor = msgs[0].id; if (msgs.length) oldestCursor = msgs[0].id;
const nodes = await Promise.all(msgs.map(renderMessage)); const nodes = await Promise.all(msgs.map(renderMessage));
if (prepend) msgHost.prepend(...nodes); if (prepend) {
else { // 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.append(...nodes);
msgHost.scrollTop = msgHost.scrollHeight; 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. // ULID ids embed a 48-bit ms timestamp in the first 10 chars.
function ulidTime(id) { function ulidTime(id) {
const A = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; const A = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
@@ -126,11 +147,13 @@ document.querySelectorAll('[data-cmd]').forEach((btn) => {
}); });
composer.addEventListener('keydown', (e) => { composer.addEventListener('keydown', (e) => {
if (composer.dataset.mentionActive === '1') return; // mention picker owns Enter
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
form.requestSubmit(); form.requestSubmit();
} }
}); });
attachMentions(composer);
let typingTimer = null; let typingTimer = null;
composer.addEventListener('input', () => { composer.addEventListener('input', () => {
@@ -197,8 +220,9 @@ const typingNames = new Map();
subscribe('chat', async (event, data) => { subscribe('chat', async (event, data) => {
if (event === 'message' && data) { if (event === 'message' && data) {
if (current && data.conversationId === current.id) { if (current && data.conversationId === current.id) {
const nearBottom = msgHost.scrollHeight - msgHost.scrollTop - msgHost.clientHeight < 120;
msgHost.appendChild(await renderMessage(data)); msgHost.appendChild(await renderMessage(data));
msgHost.scrollTop = msgHost.scrollHeight; if (nearBottom) msgHost.scrollTop = msgHost.scrollHeight;
api('POST', `/api/v1/conversations/${current.id}/read`, {}).catch(() => {}); api('POST', `/api/v1/conversations/${current.id}/read`, {}).catch(() => {});
} else { } else {
loadConversations(); loadConversations();
@@ -297,6 +321,113 @@ document.getElementById('newconv-form').addEventListener('submit', async (e) =>
} catch (err) { toast(err.message, 'err'); } } 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 () => { (async () => {
try { try {
const me = await api('GET', '/api/v1/auth/me'); const me = await api('GET', '/api/v1/auth/me');
+4 -1
View File
@@ -16,9 +16,12 @@ const themeBtn = document.getElementById('nav-theme');
if (themeBtn) { if (themeBtn) {
themeBtn.addEventListener('click', async () => { themeBtn.addEventListener('click', async () => {
const el = document.documentElement; 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; el.dataset.theme = next;
try { localStorage.setItem('theme', next); } catch (e) { /* ignore */ } try { localStorage.setItem('theme', next); } catch (e) { /* ignore */ }
toast('Theme: ' + next);
if (document.body.dataset.loggedIn === '1') { if (document.body.dataset.loggedIn === '1') {
try { try {
await api('PATCH', '/api/v1/profile', { settings: { theme: next } }); await api('PATCH', '/api/v1/profile', { settings: { theme: next } });
+5 -1
View File
@@ -56,12 +56,16 @@ form.addEventListener('submit', async (e) => {
links, links,
}, },
extra, 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; version = res.user.version;
const theme = res.user.settings.theme; const theme = res.user.settings.theme;
document.documentElement.dataset.theme = theme; document.documentElement.dataset.theme = theme;
try { localStorage.setItem('theme', theme); } catch (err) { /* ignore */ } try { localStorage.setItem('theme', theme); } catch (err) { /* ignore */ }
document.body.dataset.nav = res.user.settings.navLayout || 'side';
okBox.textContent = 'Profile saved.'; okBox.textContent = 'Profile saved.';
} catch (err) { } catch (err) {
if (err.status === 409) { if (err.status === 409) {
+57
View File
@@ -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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[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();
+1 -1
View File
@@ -26,7 +26,7 @@ async function load() {
return card; return card;
})); }));
if (!res.tasks.length) { 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; } } catch (e) { errorBox.textContent = e.message; }
} }
+23 -2
View File
@@ -37,6 +37,14 @@ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[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() { function hideCard() {
if (cardEl) { cardEl.remove(); cardEl = null; } if (cardEl) { cardEl.remove(); cardEl = null; }
} }
@@ -49,6 +57,7 @@ async function showCard(target, userId) {
cardEl = document.createElement('div'); cardEl = document.createElement('div');
cardEl.className = 'hovercard card'; cardEl.className = 'hovercard card';
const roles = ['admin', 'consultant', 'developer'].filter((r) => c.roles[r]); const roles = ['admin', 'consultant', 'developer'].filter((r) => c.roles[r]);
const links = ((c.contact && c.contact.links) || []).filter(Boolean);
cardEl.innerHTML = ` cardEl.innerHTML = `
<div class="spread"> <div class="spread">
${c.avatarFileId ? `<img class="avatar large" src="/files/${c.avatarFileId}" alt="">` ${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(' ')} ${roles.map((r) => `<span class="badge">${r}</span>`).join(' ')}
</div> </div>
</div> </div>
${c.bio ? `<p class="muted">${esc(c.bio.slice(0, 160))}</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.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); document.body.appendChild(cardEl);
const rect = target.getBoundingClientRect(); const rect = target.getBoundingClientRect();
cardEl.style.left = Math.min(rect.left, window.innerWidth - 320) + 'px'; cardEl.style.left = Math.min(rect.left, window.innerWidth - 320) + 'px';
@@ -81,3 +94,11 @@ document.addEventListener('mouseout', (e) => {
hideTimer = setTimeout(hideCard, 250); 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
View File
@@ -1,5 +1,6 @@
import { api, toast } from '/static/js/api.js'; import { api, toast } from '/static/js/api.js';
import { subscribe } from '/static/js/ws.js'; import { subscribe } from '/static/js/ws.js';
import { attachMentions, mentionHTML } from '/static/js/mention.js';
const root = document.getElementById('task-root'); const root = document.getElementById('task-root');
const errorBox = document.getElementById('error'); const errorBox = document.getElementById('error');
@@ -57,7 +58,7 @@ async function render() {
const comments = await Promise.all((t.comments || []).map(async (c) => ` const comments = await Promise.all((t.comments || []).map(async (c) => `
<div class="card" style="padding:12px"> <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> <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>`)); </div>`));
const timeline = await Promise.all((t.timeline || []).slice().reverse().map(async (e) => ` const timeline = await Promise.all((t.timeline || []).slice().reverse().map(async (e) => `
@@ -146,6 +147,18 @@ function renderActions() {
catch (e) { toast(e.message, 'err'); } 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 (root.dataset.isDeveloper === '1') {
if (t.status === 'published' && !myClaim()) add('Request assignment', post('claim', { note: '' }), true); if (t.status === 'published' && !myClaim()) add('Request assignment', post('claim', { note: '' }), true);
if (myClaim() && ['published', 'claim_requested'].includes(t.status)) add('Withdraw request', post('claim/withdraw')); if (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) => { document.getElementById('comment-form').addEventListener('submit', async (e) => {
e.preventDefault(); 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 { 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(); load();
} catch (err) { toast(err.message, 'err'); } } catch (err) { toast(err.message, 'err'); }
}); });
attachMentions(document.getElementById('comment-body'));
const timeForm = document.getElementById('time-form'); const timeForm = document.getElementById('time-form');
if (timeForm) { if (timeForm) {
timeForm.addEventListener('submit', async (e) => { timeForm.addEventListener('submit', async (e) => {
+3 -1
View File
@@ -5,7 +5,9 @@
var el = document.documentElement; var el = document.documentElement;
var saved = null; var saved = null;
try { saved = localStorage.getItem('theme'); } catch (e) { /* private mode */ } try { saved = localStorage.getItem('theme'); } catch (e) { /* private mode */ }
var THEMES = ['light', 'dark', 'neon', 'terminal', 'blueprint', 'sunset'];
var theme = saved || el.dataset.defaultTheme || 'light'; 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.theme = theme;
el.dataset.themes = THEMES.join(',');
})(); })();
+5
View File
@@ -84,6 +84,11 @@
<select id="c-consultants" multiple size="4"></select> <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> <p class="hint">Hold Ctrl/Cmd to select multiple. At least one is needed for syncing.</p>
</div> </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"> <div class="spread">
<span id="c-test-result" class="hint" aria-live="polite"></span> <span id="c-test-result" class="hint" aria-live="polite"></span>
<span> <span>
+9
View File
@@ -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}}
+6 -3
View File
@@ -3,18 +3,20 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <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"> <link rel="stylesheet" href="/static/css/app.css">
<script src="/static/js/theme.js"></script> <script src="/static/js/theme.js"></script>
</head> </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"> <nav class="topnav">
<a class="brand" href="/">Bounty Board</a> <a class="brand" href="/">{{.Brand}}</a>
<div class="links"> <div class="links">
{{if .User}} {{if .User}}
{{if .User.Roles.Developer}} {{if .User.Roles.Developer}}
<a href="/board" {{if eq .Active "board"}}aria-current="page"{{end}}>Bounty Board</a> <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> <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}} {{end}}
{{if .User.Roles.Consultant}} {{if .User.Roles.Consultant}}
<a href="/consultant/board" {{if eq .Active "atomization"}}aria-current="page"{{end}}>Atomization</a> <a href="/consultant/board" {{if eq .Active "atomization"}}aria-current="page"{{end}}>Atomization</a>
@@ -26,6 +28,7 @@
{{end}} {{end}}
<a href="/messages" {{if eq .Active "messages"}}aria-current="page"{{end}}>Messages</a> <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="/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}} {{end}}
</div> </div>
<div class="right"> <div class="right">
+6 -8
View File
@@ -1,7 +1,7 @@
{{define "content"}} {{define "content"}}
<header class="masthead"> <header class="masthead">
<p class="masthead-kicker">Consulting Work Exchange</p> <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> <p class="masthead-rule"></p>
</header> </header>
<div class="card"> <div class="card">
@@ -16,15 +16,13 @@
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required> <input type="password" id="password" name="password" autocomplete="current-password" required>
</div> </div>
<div class="spread"> <div class="login-actions">
<button class="btn primary" type="submit">Log in</button> <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> </div>
</form> </form>
{{if .OIDCEnabled}} <p class="muted mt" style="margin-bottom:0">
<div class="mt"> <a href="/forgot-password">Forgot password?</a> · <a href="/register">Create an account</a>
<a class="btn" href="/api/v1/auth/oidc/login">Continue with SSO</a> </p>
</div>
{{end}}
</div> </div>
{{end}} {{end}}
+20
View File
@@ -6,12 +6,17 @@
<h2>Messages</h2> <h2>Messages</h2>
<button class="btn small primary" id="conv-new"> New</button> <button class="btn small primary" id="conv-new"> New</button>
</div> </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="conv-list" class="conv-list"></ul>
<ul id="search-results" class="conv-list" hidden></ul>
</aside> </aside>
<section class="chat-main card"> <section class="chat-main card">
<div class="spread"> <div class="spread">
<h2 id="chat-title">Select a conversation</h2> <h2 id="chat-title">Select a conversation</h2>
<span style="display:flex;align-items:center;gap:10px">
<span class="muted" id="typing-indicator" aria-live="polite"></span> <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>
<div id="chat-messages" class="chat-messages" aria-live="polite"></div> <div id="chat-messages" class="chat-messages" aria-live="polite"></div>
<form id="chat-form" hidden> <form id="chat-form" hidden>
@@ -66,6 +71,21 @@
</form> </form>
</dialog> </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"> <dialog id="lightbox" class="lightbox">
<img id="lightbox-img" alt=""> <img id="lightbox-img" alt="">
</dialog> </dialog>
+12 -1
View File
@@ -58,7 +58,18 @@
<label for="p-theme">Theme</label> <label for="p-theme">Theme</label>
<select id="p-theme"> <select id="p-theme">
<option value="light" {{if eq .User.Settings.Theme "light"}}selected{{end}}>Light (Y2K paper)</option> <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> </select>
</div> </div>
+4
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{{define "content"}} {{define "content"}}
<header class="masthead"> <header class="masthead">
<p class="masthead-kicker">Consulting Work Exchange</p> <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> <p class="masthead-rule"></p>
</header> </header>
<div class="card"> <div class="card">