30 Commits

Author SHA1 Message Date
etalon 33c9c39676 feat(atomize): serve Subdivide via Anypreta Issue Atomizer
Add an optional remote atomization backend (the Anypreta "Issue Atomizer"
API) for the Subdivide flow, selected by ATOMIZER_REMOTE_BASE_URL /
ATOMIZER_REMOTE_API_KEY. When set, POST /v1/atomize is served by the remote
/v1/atomize-issue endpoint; its two-level issue→features→tasks model is mapped
onto our one-level task→children model by treating each Feature as a child and
its `estimation` as the effort coefficient (renormalized to sum to 1, even
split as a reported fallback when estimations are absent).

Extend and Summarize have no counterpart in the remote API and always stay on
ATOMIZER_BASE_URL, so the §5.1 service must keep running; readiness now probes
both backends. The mandatory `system` object is derived from the customer,
with a generic component tree (an empty tree makes the service return zero
features).

extsvc also learns the remote's flat error envelope ({error_code,message} and
FastAPI {detail}) and never swallows an unrecognized 4xx body.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 17:49:41 +02:00
etalon 39ec958f04 Merge pull request 'fix(jira): migrate to /rest/api/3/search/jql (legacy /search removed)' (#1) from fix/jira-search-jql-migration into main
Reviewed-on: #1
2026-07-15 15:37:39 +00:00
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
etalon 05dc91410f fix: customer-based board visibility, chat widget UX, atomized Subdivide, static cache-busting
- Board visibility (§4.4): developers now see tasks for any customer with a
  consultant who pooled them, not only tasks whose consultantId is in their
  pool. Fixes a published task being invisible to its own author who is both
  consultant and developer. Adds TestBoardVisibilityIsCustomerBased.
- Chat widget: working ✕ close, distinct (non-ghost) header buttons, reliable
  conversation switching, ← Back, title ellipsis.
- Atomization: atomized/imported subtasks now expose a Subdivide button.
- Static assets: serve with ETag + Cache-Control: no-cache so JS/CSS fixes
  reach clients immediately instead of being masked by stale max-age caching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 10:20:06 +02:00
etalon c59aea42ef feat: Y2K dither theme, filter toolbars, chat bubble widget; fix alignment root causes
- theme rework (user request): white paper bg, brown ink, ordered-dither
  halftone textures, hard offset Y2K shadows, dithered masthead bands;
  dark theme matched; token test + DECISIONS updated
- fix: .card + .card stacking margin leaked into grid layouts, shifting
  every card except the first (the 'always the first item' reports)
- fix: CSP style-src 'self' silently dropped every inline style attribute
  (misaligned save button, stat values, editor attach button, bell badge);
  styles now allow inline, scripts remain strict per §12
- fix: [hidden] is now display:none !important so flex containers cannot
  defeat it (chat panel/footers)
- board + metrics filters live in boxed .toolbar rows with baseline-aligned
  controls; metric stat cards use a uniform .stat layout
- new-conversation dialog: fixed 440px width and 180px results list (no
  more resizing while searching), full-width result rows, picked people
  drop out of the list
- floating messages bubble bottom-right on all pages (except /messages):
  unread badge, mini panel with conversation list, thread view, quick
  composer, live WS updates; toasts moved up to clear it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:54:27 +02:00
etalon b5ebbfb748 feat: public access via reverse proxy + 'The Ledger' UI design pass
- compose publishes 8787 on all interfaces (NPM fronts it at
  bountyboard.anypreta.com); APP_BASE_URL + TRUSTED_PROXY_CIDRS configured,
  client-IP resolution through the proxy verified live
- design: self-hosted variable fonts (Fraunces display serif, Schibsted
  Grotesk UI, Spline Sans Mono ledger numerals), paper-grain overlay,
  hairline double rules, letterpress buttons, stamped badges, banknote
  bounty chips, ledger tables, staggered page reveal (reduced-motion safe)
- §10 tokens, 2px radius, both themes, AA contrast preserved exactly;
  no build step, CSP-clean (fonts/img self/data)
- login/register masthead; headless-chrome screenshots verified both themes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:38:20 +02:00
etalon 7de1086725 fix: ticketing identity lookup after BSON round trip + WeKan live test
- live testing against a real WeKan v9.36 found that the driver decodes
  nested extra.ticketingIdentities as bson.D, so identity lookups silently
  returned empty and consultants were skipped; handle bson.D/bson.M/map
- integration regression test pinning the BSON round trip
- opt-in live test (go test -tags=wekanlive) verifying TestConnection,
  FetchUpdated (assignee + member fallback), key listing, since-filter and
  unknown-identity rejection against a real instance

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:25:04 +02:00
etalon f3897907a3 feat: WeKan ticketing source
- wekan connector: board id as project key, cards assigned to the
  consultant's WeKan username (assignees with member fallback), archived
  cards skipped, client-side modifiedAt since-filtering, username/password
  login with token reuse (or pre-issued token), case-insensitive identity
- fake-WeKan unit tests: factory validation, test-connection, fetch
  filtering, key listing, token reuse
- admin customer wizard: WeKan option + credential fields
- OpenAPI enum + credential shapes, README identity mapping docs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:15:34 +02:00
etalon 6265ffa894 feat: live deployment with automated acceptance checklist (phase 13)
- APP_INTERNAL_URL: in-network base URL for §5.2 callbacks and signed
  attachment URLs handed to the external services (compose: http://app:8787)
- work-performer image runs as the node user with ~/.claude mounted into
  /home/node — the claude CLI refuses --dangerously-skip-permissions as root
- scripts/acceptance.sh: re-run-safe live verification of the §13 checklist
  (demo import within one poll, subdivide sum=1 + editable, extend sibling,
  publish/bounty math, decline/claim/approve, changes-requested loop,
  approval award in metrics, unassign, AI job through real Claude Code with
  signed idempotent callback, breaker independence between the two services)
- README/DECISIONS: sudo HOME gotcha, internal URL, non-root performer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:06:23 +02:00
etalon c69c028028 feat: seed script, forgot-password, hover cards, shortcuts, OpenAPI docs, runbook (phase 12)
- scripts/seed.go: idempotent demo data per §11.11 (make seed)
- forgot/reset password: SMTP-gated, one-shot TTL tokens, uniform responses
  against enumeration, sessions revoked on reset; login page link + pages
- profile hover cards on [data-user-card] elements (§11.13)
- keyboard shortcuts: g b/m/t/h navigation, / focuses search (§10)
- bulk archive endpoint (§11.9)
- hand-written OpenAPI 3.1 covering §6, served at /api/docs + yaml download
- make backup / make restore (mongodump archive via the mongo container)
- README: quick start, demo data, runbook, breaker/job operations, working
  Caddy + nginx reverse-proxy samples (WS block, client_max_body_size),
  documented later-stubs (§11.24)
- smoke.sh now exercises register → logout → login → me → board → pages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:39:38 +02:00
etalon d698f70c4f feat: mock atomizer and work-performer services with contract tests (phase 11)
- services/atomizer: standalone Go module implementing §5.1 — Anthropic
  OpenAI-compatible chat completions by default, native /v1/messages when
  LLM_API_STYLE=anthropic, strict-JSON prompting, defensive fence-stripping
  parse with one retry, deterministic equal-split fallback without an API
  key, exact coefficient-sum normalization, bearer auth
- services/work-performer: Node 20 http server implementing §5.2 — single
  concurrency, /work/{jobId}/TASK.md preparation, attachment downloads,
  optional shallow git clone, claude CLI execution with JSON output,
  simulated success when the CLI is unavailable (offline demo), artifact
  endpoint, idempotent HMAC-signed callbacks with retry, best-effort cancel
- compose profile 'mocks': separate builds/ports/tokens, healthchecks,
  ${HOME}/.claude(.json) mounted read-only into the performer
- contract tests (go test -tags=contract) for both services; Makefile
  test-contract target; verified live incl. a real Claude Code job run

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:31:39 +02:00
etalon b1c9a42810 feat: metrics dashboards with SVG charts, CSV export, leaderboard (phase 10)
- aggregations over the immutable bountyAwards ledger: totals, weekly
  buckets ($dateTrunc), per-developer and per-customer groupings
- timeline-derived: approval rate, time logged, assigned→approved lead
  time, imported→published atomization lead time, open board depth
- developer + consultant dashboards (admin = global consultant view),
  date-range filters, CSV export of the ledger
- leaderboard (top 10 by bounty) honoring the new leaderboardOptOut
  profile setting
- hand-rolled SVG line and bar charts (~150 lines, no chart library)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:19:57 +02:00
etalon 7d3140334e feat: messaging with conversations, rich text, attachments, typing, unread (phase 9)
- conversations: dm (unique pair, find-or-create), group (titled), project
  (customer-bound); participant-only access
- messages: server-sanitized rich text, attachments referencing uploads
  from the generic POST /api/v1/files endpoint (rate limited, MIME sniffed)
- unread counts via aggregation; mark-read pushes readBy receipts
- chat files served only to conversation participants (or uploader)
- @mentions in chat notify the mentioned participant
- typing indicator: client WS event relayed to other participants
- WS targeted delivery (SendTo) + 15s polling fallback
- two-pane messages UI: conversation list with unread badges, composer
  (contenteditable + formatting buttons), drag & drop upload, inline image
  previews with lightbox, new-conversation dialog with user search

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:15:56 +02:00
96 changed files with 8194 additions and 300 deletions
+13
View File
@@ -0,0 +1,13 @@
# Container-owned data: unreadable by the build user, and stat'ing it fails the
# context walk outright.
.volumes/
backups/
.git/
.env
.env.*
!.env.example
# Local working backups.
.bak-*/
**/.bak-*/
+9
View File
@@ -1,6 +1,9 @@
# --- core ---
# Set APP_BASE_URL to the public https URL when behind the reverse proxy.
APP_BASE_URL=http://localhost:8787
# In-network address external services use to call back into the app.
# docker-compose.yml sets this to http://app:8787; defaults to APP_BASE_URL.
#APP_INTERNAL_URL=
# Comma-separated CIDRs of trusted reverse proxies, e.g. 172.16.0.0/12,10.0.0.0/8.
# Empty = trust no proxy headers.
TRUSTED_PROXY_CIDRS=
@@ -21,6 +24,12 @@ ADMIN_INITIAL_PASSWORD=change-me-now
ATOMIZER_BASE_URL=http://atomizer-mock:8090
ATOMIZER_TOKEN=change-me-atomizer
ATOMIZER_TIMEOUT_SEC=120
# Optional: serve Subdivide from the Anypreta Issue Atomizer instead of the
# §5.1 service above. Both vars are required to enable it. Extend and Summarize
# have no counterpart in that API and always stay on ATOMIZER_BASE_URL, so the
# service above must keep running either way.
ATOMIZER_REMOTE_BASE_URL=
ATOMIZER_REMOTE_API_KEY=
WORK_PERFORMER_BASE_URL=http://work-performer:8091
WORK_PERFORMER_TOKEN=change-me-performer
# Job submission/polling only; jobs themselves are async.
+5
View File
@@ -3,3 +3,8 @@ bin/
backups/
*.test
coverage.out
# Docker volume data (bind-mounted under the repo; never committed)
/.volumes/
.env.bak*
.env.local
+62
View File
@@ -104,3 +104,65 @@ Spec-silent choices, recorded as required by the build instructions.
/change-password.
- **Pages for later phases render an "under construction" placeholder** so
role-based navigation is complete and clickable now.
## Phases 512 (selected)
- **`internal/extsvc`** (not in the §14 list) holds the circuit breaker and
retrying JSON client shared by the two service clients — sharing plumbing,
not state; each service keeps its own breaker, URL, and token.
- **Mock services are separate codebases**: `services/atomizer` has its own
`go.mod` (stdlib only); `services/work-performer` is plain Node with zero
npm dependencies (plus the globally installed claude CLI in its image).
- **AI-approved tasks earn no `bountyAwards` row** — §4.5 awards are a
developer performance ledger; the AI submitter has no developer identity.
- **Bulk archive/publish are partial-success APIs** returning
`{done[], failed{}}` instead of failing the whole batch.
- **Extension siblings record `parentId = source task`** (spec literal),
so the UI tree shows extensions beneath their source.
## Phase 13 (deployment)
- **`APP_INTERNAL_URL` (compose: `http://app:8787`)** is handed to the
external services for callback URLs and signed attachment URLs — the §5.2
example uses the in-network hostname; `APP_BASE_URL` stays browser-facing.
- **The work-performer container runs as the `node` user** (uid 1000) with
`${HOME}/.claude` mounted into `/home/node/` instead of `/root/` (§9.2
shows /root): the claude CLI refuses `--dangerously-skip-permissions` as
root, so the literal spec mount can never execute jobs.
- **Run compose with the real user's HOME** — `sudo docker compose` resolves
`${HOME}` to `/root` and silently mounts the wrong Claude credentials. Use
`sudo --preserve-env=HOME docker compose …` (or run docker unprivileged).
- **UFW**: a rule allowing `172.16.0.0/12` (docker networks) to reach the
host was added so container→host callbacks work in contract tests.
- **`scripts/acceptance.sh`** automates the §13 checklist live (import →
subdivide → extend → publish → claim/decline/approve → review → award →
AI job via real Claude Code → breaker independence) and is re-run-safe.
## Post-deploy additions
- **WeKan connector**: `projectKey` is the board id; "assigned to the
consultant" means the consultant's WeKan username (from
`ticketingIdentities.wekan`) appears in a card's `assignees` (falling back
to `members` when no assignee is set). Archived cards are skipped. WeKan
has no epic/story hierarchy → all cards map to type `task`; the API offers
no server-side updated-since filter, so the connector filters on
`modifiedAt` client-side (one details request per card — fine for board
sizes WeKan handles). Auth: username+password login per sync with a 10-min
token reuse window, or a pre-issued token. Card attachments are not
imported in v1.
## Remote access + UI design pass (user-requested)
- **App published on 0.0.0.0:8787** (spec default loopback-only kept as a
commented line in compose): this host's pattern exposes services directly
(gitea/outline/wekan) and Docker-published ports bypass UFW anyway. The
public entrypoint is Nginx Proxy Manager → http://bountyboard.anypreta.com
with `APP_BASE_URL` set accordingly and `TRUSTED_PROXY_CIDRS=172.16.0.0/12`
so audit-log client IPs resolve through the proxy.
- **"The Ledger" design pass** (user invoked the frontend-design skill):
§10 tokens, 2px radius, AA contrast and both themes unchanged; typography
deviates from the spec's system font stack by request — self-hosted
variable woff2 (Fraunces display, Schibsted Grotesk body, Spline Sans Mono
for ledger numerals), ~147 KB total, no build step, CSP font-src 'self'.
Adds paper grain, hairline double rules, letterpress buttons, stamp
badges, staggered page reveal (disabled under prefers-reduced-motion).
+18 -4
View File
@@ -4,6 +4,7 @@ GO ?= go
build:
$(GO) build ./...
cd services/atomizer && $(GO) build ./...
run:
$(GO) run ./cmd/app
@@ -16,19 +17,32 @@ test:
test-integration:
$(GO) test -tags=integration ./...
# Requires the mock services running:
# docker compose --profile mocks up -d --build atomizer-mock work-performer
test-contract:
ATOMIZER_TOKEN=$$(grep '^ATOMIZER_TOKEN=' .env | cut -d= -f2-) \
WORK_PERFORMER_TOKEN=$$(grep '^WORK_PERFORMER_TOKEN=' .env | cut -d= -f2-) \
$(GO) test -tags=contract -count=1 -timeout=8m ./internal/contract/
lint:
@unformatted=$$(gofmt -l .); if [ -n "$$unformatted" ]; then \
@unformatted=$$(gofmt -l $$(git ls-files '*.go')); if [ -n "$$unformatted" ]; then \
echo "gofmt needed on:"; echo "$$unformatted"; exit 1; fi
$(GO) vet ./...
# Requires Mongo published on loopback (test overlay) or MONGO_SEED_URI.
seed:
@echo "seed script lands in phase 12 (scripts/seed.go)"; exit 1
$(GO) run ./scripts
smoke:
./scripts/smoke.sh
# mongodump/mongorestore through the mongo container into ./backups (§11.23)
backup:
@echo "backup target lands in phase 12"; exit 1
@mkdir -p backups
docker compose exec -T mongo mongodump --archive --db=bountyboard > backups/bountyboard-$$(date +%Y%m%d-%H%M%S).archive
@ls -lh backups/ | tail -1
# usage: make restore FILE=backups/bountyboard-20260612-120000.archive
restore:
@echo "restore target lands in phase 12"; exit 1
@test -n "$(FILE)" || (echo "usage: make restore FILE=backups/<archive>"; exit 1)
docker compose exec -T mongo mongorestore --archive --drop < $(FILE)
+9
View File
@@ -9,3 +9,12 @@
- Phase 6 (sync workers): per-customer pollers with reconcile loop (start/stop/interval changes), §5.3 idempotent upsert keyed (system,key,customerId) w/ content-hash change detection, upstream edits refresh only while imported (timeline+notification after), attachment caching to GridFS, orphan flagging (never deletes), admin sync-now trigger + worker statuses, default budget prefill — demo-type integration tests green.
- Phase 7 (atomization): circuit breaker (3 fails→open, 60s half-open probe) + retrying bearer JSON client shared by both external services, atomizer client w/ §5.1 coefficient normalization (±0.001 ok, ≤0.05 renormalized, else 502-class), persisted jobs queue (panic-safe, backoff ×3, stale requeue, restart-safe), subdivide/extend endpoints (202 async, re-run replaces unpublished children after confirm), task editing w/ bounty recompute + optional budget cascade, publish single/bulk, WS hub (origin check, 30s heartbeats) + live board, consultant atomization board UI (tree, sliders, sum indicator, modals, shimmer, health gating) — unit + integration green. Fixed tasks unique index: sparse→partial (sparse compound matched every task via customerId).
- Phase 8 (bounty board + lifecycle): developer board (pool-scoped, filters/search/sort, stale age badges, hide-competing-claims setting, saved filters), claim/withdraw/decline/approve, assign-to-AI via §5.2 client + HMAC-verified idempotent callback w/ artifact ingestion into GridFS, start/submit/abandon/unassign, comments (sanitized, @mentions→notifications), time log, review queue + per-AC checklist on timeline, immutable bountyAwards on approve (humans only), notifications center + bell + WS toasts, my-tasks kanban, task detail page, pool management UI, server-side HTML sanitizer w/ XSS vector tests — full lifecycle integration tests green.
- Phase 9 (messaging): conversations (dm dedupe/group/project), sanitized rich-text messages, file/image attachments (generic POST /api/v1/files w/ rate limit), per-conversation unread counts + mark-read, chat-file access limited to participants, mentions→notifications, typing indicator over WS inbound relay, two-pane messages UI (contenteditable composer, formatting toolbar, drag&drop upload, inline previews + lightbox, live delivery, polling fallback), user directory search — integration tests green.
- Phase 10 (metrics): aggregation pipelines over bountyAwards + timelines (totals, weekly $dateTrunc buckets, per-developer/per-customer, approval rate, time logged, assign→approve lead time, atomization lead time, open board depth), CSV export, leaderboard w/ opt-out (profile setting), dependency-free SVG line+bar charts, developer/consultant/admin dashboards — integration tests green.
- Phase 11 (mock services): services/atomizer (own Go module, ports 8090, bearer auth, OpenAI-compatible chat completions + native /v1/messages via LLM_API_STYLE, strict-JSON prompt, fence-stripping defensive parse, one retry, deterministic equal-split fallback, exact sum normalization); services/work-performer (Node 20, zero deps, single-concurrency queue, TASK.md + attachment download + optional git clone, claude -p --output-format json --dangerously-skip-permissions, simulated result when CLI unavailable, artifact serving, HMAC-signed callbacks with retry); compose profile mocks w/ healthchecks + ${HOME}/.claude mounts; contract tests pass live (WP ran real Claude Code via mounted host creds). UFW rule added for container→host callbacks.
- Phase 12 (polish): seed script (make seed: demo customer + 1 admin + 2 consultants + 6 devs + pools + conversations, idempotent), forgot/reset password (SMTP-gated, one-shot TTL tokens, anti-enumeration), profile hover cards, keyboard shortcuts (g b / g m / g t / g h / focus search), bulk archive endpoint, hand-written api/openapi.yaml served at /api/docs, make backup/restore via mongodump, README w/ runbook + Caddy & nginx samples (WS + client_max_body_size), extended register→login→board smoke script — all tests green.
- Phase 13 (test & deploy): full unit (10 pkgs) + integration (12 pkgs) + contract suites green; scripts/acceptance.sh automates the §13 checklist live and PASSES end-to-end incl. a real Claude Code AI work-performer run; readyz verified 503/200 across a Mongo stop/start; smoke PASS; stack left running with mocks profile. Fixes: APP_INTERNAL_URL for in-network callbacks/signed URLs, work-performer runs as node user (claude refuses root), sudo HOME gotcha documented, UFW rule for container→host callbacks.
- Post-deploy: ANTHROPIC_API_KEY wired into .env (gitignored) — atomizer now produces real claude-sonnet-4-6 subdivisions (verified live, sum=1.0). Added WeKan ticketing source: connector (login or pre-issued token, board=projectKey, cards assigned to consultant's wekan username w/ member fallback, archived skipped, modifiedAt since-filter, token reuse), fake-server unit tests, admin wizard fields, OpenAPI/README updates.
- WeKan live verification against /opt/wekan (v9.36): connector live test green (assignee + member-fallback import, since-filter, identity rejection); full app E2E green (test-connection, customer create, sync worker import, orphan flagging after upstream unassign, real LLM subdivision of a WeKan card). Fixed live-found bug: nested extra.ticketingIdentities decodes as bson.D, identity lookup now handles bson.D/bson.M/map (regression test added).
- Remote access: NPM proxy host bountyboard.anypreta.com → 8787 (app published on 0.0.0.0, APP_BASE_URL + TRUSTED_PROXY_CIDRS=172.16.0.0/12 set, client IPs verified through proxy). UI design pass 'The Ledger': self-hosted Fraunces/Schibsted Grotesk/Spline Sans Mono, paper grain, double rules, letterpress buttons, stamp badges, staggered reveal — §10 tokens/contrast unchanged, screenshots verified light+dark.
- UI iteration: granted all roles to spam@marsal.xyz; Y2K white/brown dithered theme (user-requested §10 deviation; tokens+test updated); boxed .toolbar for board/metrics filters w/ baseline alignment; stat cards (.stat) equal-height/aligned; fixed root causes: .card+.card margin leaking into grids ('always the first item'), CSP style-src blocking ALL inline style attributes (now 'unsafe-inline' for styles only, scripts stay strict), display:flex defeating [hidden]; new-conversation dialog fixed geometry + row list; floating messages bubble w/ unread badge + mini panel (list/thread/composer, WS live) on every page.
+194
View File
@@ -0,0 +1,194 @@
# Bounty Board
A consulting work-management platform: imports tickets from Jira / Azure
DevOps / YouTrack / WeKan (or an offline `demo` source), lets **consultants** atomize
them into small developer tasks with AI assistance, publishes them on a
**bounty board** where **developers** claim work (or an **AI work performer**
does it), and tracks review, approval, and bounty-based metrics.
Built per [specification.md](specification.md): Go ≥ 1.22 + stdlib `net/http`,
MongoDB, server-rendered templates + vanilla ES modules (no build step),
Docker Compose deployment. Architectural decisions are logged in
[DECISIONS.md](DECISIONS.md); build history in [PROGRESS.md](PROGRESS.md).
## Quick start
```bash
cp .env.example .env
# then edit .env:
# SESSION_SECRET=$(openssl rand -base64 32)
# CREDENTIALS_ENC_KEY=$(openssl rand -base64 32)
# ADMIN_INITIAL_PASSWORD=<something strong>
# ATOMIZER_TOKEN / WORK_PERFORMER_TOKEN = random strings
# ANTHROPIC_API_KEY=<your key> # optional; offline fallback without it
docker compose --profile mocks up -d --build
open http://localhost:8787
```
> **Note:** if you run compose via `sudo`, use
> `sudo --preserve-env=HOME docker compose …` — otherwise `${HOME}` resolves
> to `/root` and the work performer mounts the wrong `~/.claude` credentials.
First login: `ADMIN_EMAIL` / `ADMIN_INITIAL_PASSWORD` from `.env` — a
password change is forced immediately.
Without `--profile mocks` only `app` + `mongo` start and the external
service URLs in `.env` must point at real implementations of the §5
contracts.
### Demo data
```bash
docker compose -f docker-compose.yml -f docker-compose.test.yml up -d # publishes mongo on loopback
make seed
```
Seeds a demo customer (offline `demo` ticketing — tickets appear within one
poll), consultants `clara@example.com` / `carlos@example.com`, developers
`dev1@example.com``dev6@example.com` (password `demo-pass-123` for all),
pools, and sample conversations.
## Services
| Service | Port (loopback) | What |
|---|---|---|
| `app` | 8787 | Bounty Board (UI + API + WS) |
| `mongo` | — (never published; test overlay adds 27017) | database |
| `atomizer-mock` | 8090 (profile `mocks`) | §5.1 Atomization Service — Anthropic chat completions (or native `/v1/messages` via `LLM_API_STYLE=anthropic`), deterministic fallback without an API key |
| `work-performer` | 8091 (profile `mocks`) | §5.2 Work Performer — runs Claude Code with the host's `~/.claude` mounted read-only; simulated result when the CLI is unavailable |
The two external services are intentionally independent: separate codebases
(`services/atomizer`, `services/work-performer`), containers, ports, and
bearer tokens. Swap either by changing its base URL + token in `.env`.
## Development
```bash
make build # app + atomizer mock
make test # unit tests (no Mongo needed)
make test-integration # needs mongo on loopback (test overlay)
make test-contract # needs the mocks profile running
make lint # gofmt + go vet
make run # run the app locally (expects Mongo + .env)
make smoke # curl smoke test against a running stack
```
API reference: `GET /api/docs` (rendered) or [api/openapi.yaml](api/openapi.yaml).
## Operations runbook
**Logs** — JSON to stdout: `docker compose logs -f app` (request ids, sync
runs, job retries). `docker compose logs -f atomizer-mock work-performer`
for the mocks.
**Health**`GET /healthz` (liveness), `GET /readyz` (Mongo required;
atomizer/work-performer reported non-fatally), `GET /metricsz` (JSON
counters: requests, sync runs, job queue depths). The admin UI →
*Service status* shows live health, latency, circuit-breaker state, sync
workers, and queue depths.
**Backups** (§11.23):
```bash
make backup # mongodump → ./backups/bountyboard-<ts>.archive
make restore FILE=backups/<archive> # mongorestore --drop
```
Cron example: `0 3 * * * cd /opt/bountyboard && make backup >/dev/null`.
**Circuit breakers** — after 3 consecutive failures calls to an external
service stop for 60 s (one half-open probe per minute afterwards). The
consultant board disables Subdivide/Extend while the atomizer is down; AI
assignment fails fast while the work performer is down. Each service has an
independent breaker.
**Stuck jobs** — background jobs (`jobs` collection) retry 3× with backoff;
jobs stuck `running` after a crash are re-queued automatically within
5 minutes.
## Reverse proxy
The app expects a TLS-terminating proxy (§12). Set in `.env`:
```dotenv
APP_BASE_URL=https://bounty.example.com
TRUSTED_PROXY_CIDRS=172.16.0.0/12 # the proxy's source range as seen by the app
COOKIE_SECURE=auto
```
`X-Forwarded-*` headers are honored only from `TRUSTED_PROXY_CIDRS`; the
resolved client IP feeds rate limiting and the audit log. WebSocket
heartbeats (30 s) keep idle proxy timeouts from killing `/ws`.
### Caddy
```caddyfile
bounty.example.com {
encode gzip
reverse_proxy 127.0.0.1:8787
# WebSockets are proxied automatically; raise the body limit to match MAX_UPLOAD_MB
request_body {
max_size 25MB
}
}
```
### nginx
```nginx
server {
listen 443 ssl http2;
server_name bounty.example.com;
ssl_certificate /etc/letsencrypt/live/bounty.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/bounty.example.com/privkey.pem;
client_max_body_size 25m; # match MAX_UPLOAD_MB
location /ws {
proxy_pass http://127.0.0.1:8787;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s; # > the 30s WS heartbeat
}
location / {
proxy_pass http://127.0.0.1:8787;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}
```
## Configuration
All configuration comes from `.env` (see `.env.example` for every knob with
comments): Mongo, session/credential secrets, bootstrap admin, the two
external service URLs + tokens + timeouts, SMTP (enables forgot-password
emails when set), OIDC SSO (buttons appear when set), upload limit, rate
limits, and the mock services' Anthropic settings.
Consultants map their ticketing identities in their profile via
`users.extra.ticketingIdentities` (e.g. set by an admin):
`{"jira": "consultant@corp.com", "azure_devops": "consultant@corp.com", "youtrack": "consultant.login", "wekan": "wekan-username"}`.
For WeKan the customer's project key is the **board id** and cards assigned
to the consultant (assignees, falling back to members) are imported.
The `demo` ticketing type needs no identity.
## Deliberately out of scope (documented stubs, §11.24)
- **Webhook ingestion** instead of polling — the sync layer is keyed on an
idempotent `(system, key, customerId)` upsert, so a webhook receiver can
reuse `UpsertImportedTask` unchanged.
- **Status write-back** to customer systems — v1 sync is read-only.
- **Email digests** — notifications are in-app + WS; the mailer exists and
is used for password resets.
- **Production AI work performer** — the §5.2 HTTP contract is final; the
shipped container is a placeholder.
- **Localization** — UI strings are English-only.
+7
View File
@@ -0,0 +1,7 @@
// Package api embeds the hand-written OpenAPI document (§6).
package api
import _ "embed"
//go:embed openapi.yaml
var OpenAPI []byte
+289
View File
@@ -0,0 +1,289 @@
openapi: "3.1.0"
info:
title: Bounty Board API
version: "1.0"
description: |
Consulting work-management platform API (spec §6). All endpoints are
session-authenticated (httpOnly cookie `bb_session`); mutations also
require the CSRF double-submit header `X-CSRF-Token` mirroring the
`bb_csrf` cookie. Errors use the envelope
`{"error":{"code":"…","message":"…"}}`. Pagination: `?limit=&cursor=<ulid>`.
servers:
- url: /api/v1
tags:
- {name: auth}
- {name: admin}
- {name: consultant}
- {name: developer}
- {name: tasks}
- {name: messaging}
- {name: shared}
- {name: internal}
paths:
/auth/register:
post:
tags: [auth]
summary: Self-register a developer account
requestBody: {$ref: "#/components/requestBodies/Register"}
responses:
"201": {description: Account created and session started}
"409": {description: Email already registered}
/auth/login:
post:
tags: [auth]
summary: Local login
requestBody: {$ref: "#/components/requestBodies/Login"}
responses:
"200": {description: "Session cookies set; body: user + mustChangePassword"}
"401": {description: Invalid credentials}
"429": {description: Rate limited (10 attempts / 15 min / IP+email)}
/auth/logout:
post: {tags: [auth], summary: Revoke the current session, responses: {"204": {description: Logged out}}}
/auth/logout-all:
post: {tags: [auth], summary: Revoke every session of the current user, responses: {"200": {description: Count of revoked sessions}}}
/auth/change-password:
post:
tags: [auth]
summary: Change the local password (revokes other sessions)
responses: {"204": {description: Changed}, "401": {description: Wrong current password}}
/auth/forgot:
post:
tags: [auth]
summary: Request a password-reset email (SMTP required)
responses: {"200": {description: Uniform response}, "503": {description: SMTP not configured}}
/auth/reset:
post:
tags: [auth]
summary: Set a new password with a one-shot token
responses: {"204": {description: Password set}, "400": {description: Invalid or expired token}}
/auth/me:
get: {tags: [auth], summary: Current user, responses: {"200": {description: User profile + flags}}}
/auth/oidc/login:
get: {tags: [auth], summary: Start the OIDC code flow with PKCE (302 to the IdP), responses: {"302": {description: Redirect}}}
/auth/oidc/callback:
get: {tags: [auth], summary: OIDC redirect URI (links by verified email or creates a developer), responses: {"302": {description: Redirect home or to /login?error=…}}}
/admin/users:
get:
tags: [admin]
summary: List/search users
parameters:
- {name: q, in: query, schema: {type: string}}
- {name: role, in: query, schema: {type: string, enum: [admin, consultant, developer]}}
responses: {"200": {description: Users + nextCursor}}
/admin/users/{id}:
patch:
tags: [admin]
summary: "Update roles / disabled / name / force password reset"
responses: {"200": {description: Updated user}, "400": {description: Self-lockout guard}, "409": {description: Version conflict}}
delete: {tags: [admin], summary: Delete a user and their sessions, responses: {"204": {description: Deleted}}}
/admin/customers:
get: {tags: [admin], summary: List customers (includeArchived=true optional), responses: {"200": {description: Customers (credentials never returned)}}}
post:
tags: [admin]
summary: Create a customer with per-system credentials (encrypted at rest)
requestBody: {$ref: "#/components/requestBodies/Customer"}
responses: {"201": {description: Created}, "409": {description: Name taken}}
/admin/customers/{id}:
get: {tags: [admin], summary: Get one customer, responses: {"200": {description: Customer}}}
patch: {tags: [admin], summary: Partial update (credentials rotate when supplied), responses: {"200": {description: Updated}, "409": {description: Version conflict}}}
delete: {tags: [admin], summary: Delete (blocked while tasks exist), responses: {"204": {description: Deleted}, "409": {description: Has tasks — archive instead}}}
/admin/customers/test-connection:
post: {tags: [admin], summary: Test unsaved credentials (wizard), responses: {"200": {description: "{ok, latencyMs, error?}"}}}
/admin/customers/{id}/test-connection:
post: {tags: [admin], summary: Test the stored connection, responses: {"200": {description: "{ok, latencyMs, error?}"}}}
/admin/customers/{id}/sync-now:
post: {tags: [admin], summary: Trigger an immediate sync poll, responses: {"202": {description: Queued}}}
/admin/settings:
get: {tags: [admin], summary: Runtime settings, responses: {"200": {description: Settings}}}
patch: {tags: [admin], summary: Update runtime settings (atomizer URL override, branding, …), responses: {"200": {description: Updated settings}}}
/admin/audit-log:
get:
tags: [admin]
summary: Audit log of privileged mutations
parameters:
- {name: actorId, in: query, schema: {type: string}}
- {name: entityId, in: query, schema: {type: string}}
- {name: cursor, in: query, schema: {type: string}}
responses: {"200": {description: Entries + nextCursor}}
/admin/service-status:
get: {tags: [admin], summary: "Health/latency/breaker for atomizer + work performer, sync workers, job queue (§11.17)", responses: {"200": {description: Status payload}}}
/consultant/board:
get:
tags: [consultant]
summary: Atomization board (imported…claim_requested tasks of own customers)
parameters:
- {name: customerId, in: query, schema: {type: string}}
- {name: status, in: query, schema: {type: string}}
responses: {"200": {description: Customers + tasks}}
/consultant/reviews:
get: {tags: [consultant], summary: Tasks in review for own customers, responses: {"200": {description: Tasks}}}
/consultant/pool:
get: {tags: [consultant], summary: Global developer pool with membership flags, responses: {"200": {description: Developers}}}
post: {tags: [consultant], summary: Add a developer to my pool, responses: {"201": {description: Added}, "409": {description: Already in pool}}}
/consultant/pool/{developerId}:
delete: {tags: [consultant], summary: Remove a developer from my pool, responses: {"204": {description: Removed}}}
/consultant/metrics:
get:
tags: [consultant]
summary: "Aggregations per developer/customer + lead time + board depth (§6.2); format=csv exports the ledger"
parameters:
- {name: customerId, in: query, schema: {type: string}}
- {name: developerId, in: query, schema: {type: string}}
- {name: from, in: query, schema: {type: string, format: date}}
- {name: to, in: query, schema: {type: string, format: date}}
- {name: format, in: query, schema: {type: string, enum: [csv]}}
responses: {"200": {description: Metrics JSON or CSV}}
/board:
get:
tags: [developer]
summary: Bounty board (published tasks of consultants who pooled me)
parameters:
- {name: customerId, in: query, schema: {type: string}}
- {name: q, in: query, schema: {type: string}, description: Mongo text search}
- {name: minBounty, in: query, schema: {type: number}}
- {name: sort, in: query, schema: {type: string, enum: ["", bounty]}}
responses: {"200": {description: Tasks + customers}}
/my-tasks:
get: {tags: [developer], summary: My kanban (assigned…approved), responses: {"200": {description: Tasks}}}
/developer/metrics:
get:
tags: [developer]
summary: "Own §6.2 metrics; format=csv exports the ledger"
parameters:
- {name: from, in: query, schema: {type: string, format: date}}
- {name: to, in: query, schema: {type: string, format: date}}
- {name: format, in: query, schema: {type: string, enum: [csv]}}
responses: {"200": {description: Metrics JSON or CSV}}
/leaderboard:
get: {tags: [shared], summary: Top developers by bounty (opt-out honored), responses: {"200": {description: Leaderboard}}}
/tasks/{id}:
get: {tags: [tasks], summary: Task detail incl. timeline (role-scoped), responses: {"200": {description: Task}, "403": {description: No access}}}
patch: {tags: [consultant], summary: "Edit title/description/AC/coefficient/budget (version-checked, bounty recomputed; cascadeBudget optional)", responses: {"200": {description: Updated task}, "409": {description: Conflict or immutable}}}
/tasks/{id}/subdivide:
post: {tags: [consultant], summary: "Queue AI subdivision (§5.1); re-run needs confirmReplace", responses: {"202": {description: "{jobId}"}, "409": {description: Bad status / confirm required}}}
/tasks/{id}/extend:
post: {tags: [consultant], summary: Queue AI extension (note required), responses: {"202": {description: "{jobId}"}}}
/tasks/{id}/publish:
post: {tags: [consultant], summary: Publish an atomized task to the board, responses: {"200": {description: Task}, "409": {description: Illegal transition}}}
/tasks/publish:
post: {tags: [consultant], summary: "Bulk publish {ids:[…]}", responses: {"200": {description: "{published, failed}"}}}
/tasks/archive:
post: {tags: [consultant], summary: "Bulk archive {ids:[…]}", responses: {"200": {description: "{archived, failed}"}}}
/tasks/{id}/archive:
post: {tags: [tasks], summary: Archive (consultant/admin), responses: {"204": {description: Archived}}}
/tasks/{id}/claim:
post: {tags: [developer], summary: Request assignment (optional pitch note), responses: {"204": {description: Requested}, "409": {description: Already requested / not open}}}
/tasks/{id}/claim/withdraw:
post: {tags: [developer], summary: Withdraw my claim (back to published when none remain), responses: {"204": {description: Withdrawn}}}
/tasks/{id}/approve-claim:
post: {tags: [consultant], summary: "Approve one developer {developerId}; others are declined + notified", responses: {"204": {description: Assigned}}}
/tasks/{id}/decline-claim:
post: {tags: [consultant], summary: Decline one claim, responses: {"204": {description: Declined}}}
/tasks/{id}/assign-ai:
post: {tags: [consultant], summary: "Create a Work Performer job (§5.2) {context:{repositoryUrl,branch,instructions}}", responses: {"202": {description: "{jobId}"}, "502": {description: Performer rejected the job}}}
/tasks/{id}/unassign:
post: {tags: [consultant], summary: Return an assigned/in-progress task to the board, responses: {"204": {description: Unassigned}}}
/tasks/{id}/start:
post: {tags: [developer], summary: Start work (assigned or changes_requested), responses: {"204": {description: In progress}}}
/tasks/{id}/submit-review:
post: {tags: [developer], summary: Submit for review, responses: {"204": {description: In review}}}
/tasks/{id}/abandon:
post: {tags: [developer], summary: Abandon back to the board, responses: {"204": {description: Published}}}
/tasks/{id}/comments:
post: {tags: [tasks], summary: Add a sanitized comment (@mentions notify), responses: {"201": {description: Comment}}}
/tasks/{id}/time:
post: {tags: [developer], summary: "Log time {minutes, note}", responses: {"201": {description: Entry}}}
/tasks/{id}/review:
post: {tags: [consultant], summary: "Review {decision: approve|request_changes, note, checklist[]}; approve freezes the bounty into bountyAwards", responses: {"204": {description: Reviewed}}}
/conversations:
get: {tags: [messaging], summary: My conversations with unread counts, responses: {"200": {description: Conversations}}}
post: {tags: [messaging], summary: "Create dm (deduplicated) / group / project conversation", responses: {"200": {description: Existing dm}, "201": {description: Created}}}
/conversations/{id}/messages:
get:
tags: [messaging]
summary: Message history (newest page; cursor pages backwards)
parameters: [{name: cursor, in: query, schema: {type: string}}]
responses: {"200": {description: Messages chronological}}
post: {tags: [messaging], summary: "Send {body (sanitized rich text), attachments:[fileIds]}", responses: {"201": {description: Message}}}
/conversations/{id}/read:
post: {tags: [messaging], summary: Mark all messages read, responses: {"200": {description: "{marked}"}}}
/files:
post: {tags: [shared], summary: "Upload (multipart field `file`; scope=chat default, task for consultants)", responses: {"201": {description: "{fileId, name, mimeType, size, isImage}"}, "413": {description: Exceeds MAX_UPLOAD_MB}}}
/profile:
get: {tags: [shared], summary: Own profile, responses: {"200": {description: User}}}
patch: {tags: [shared], summary: "Partial profile update (name, bio, contact, extra, settings.theme/leaderboardOptOut); optional version → 409 on conflict", responses: {"200": {description: Updated user}}}
/profile/avatar:
post: {tags: [shared], summary: Upload avatar (must sniff as an image), responses: {"200": {description: "{fileId}"}}}
/users:
get: {tags: [shared], summary: User directory search (name/email) for messaging, responses: {"200": {description: Users}}}
/users/{id}/card:
get: {tags: [shared], summary: Public profile card (hover cards), responses: {"200": {description: Card}}}
/notifications:
get: {tags: [shared], summary: My notifications + unread count, responses: {"200": {description: Notifications}}}
/notifications/read:
post: {tags: [shared], summary: "Mark read {ids:[]} (empty = all)", responses: {"200": {description: "{marked}"}}}
/service-health:
get: {tags: [shared], summary: Atomizer + work-performer health/breaker for UI gating, responses: {"200": {description: Health}}}
/internal/work-results:
post:
tags: [internal]
summary: "Work Performer callback (§5.2): HMAC `X-Signature` over the raw body with WORK_PERFORMER_TOKEN; idempotent by jobId; artifacts ingested into GridFS"
responses:
"200": {description: Accepted or duplicate_ignored}
"401": {description: Bad signature}
components:
requestBodies:
Register:
content:
application/json:
schema:
type: object
required: [email, name, password]
properties:
email: {type: string, format: email}
name: {type: string}
password: {type: string, minLength: 8}
Login:
content:
application/json:
schema:
type: object
required: [email, password]
properties:
email: {type: string}
password: {type: string}
Customer:
content:
application/json:
schema:
type: object
required: [name, type]
properties:
name: {type: string}
type: {type: string, enum: [jira, azure_devops, youtrack, wekan, demo]}
baseUrl: {type: string}
projectKey: {type: string}
pollIntervalSec: {type: integer, minimum: 10}
defaultBudget: {type: number}
consultantIds: {type: array, items: {type: string}}
credentials:
type: object
description: "jira: {email, apiToken} · azure_devops: {organization, project, pat} · youtrack: {permanentToken} · wekan: {username, password} (projectKey = board id) · demo: {}"
schemas:
Error:
type: object
properties:
error:
type: object
properties:
code: {type: string}
message: {type: string}
+10 -1
View File
@@ -86,6 +86,8 @@ func run() error {
})
srv.SetWSHandler(hub.Handle)
srv.SetPublishFn(hub.Broadcast)
srv.SetSendTo(hub.SendTo)
hub.SetInbound(srv.HandleInboundWS)
// Atomizer client honoring the admin base-URL override per call.
atomClient := atomize.New(func() string {
@@ -96,6 +98,13 @@ func run() error {
}
return cfg.AtomizerBaseURL
}, cfg.AtomizerToken, cfg.AtomizerTimeout)
if cfg.AtomizerRemoteBaseURL != "" {
// Subdivide is served by the Anypreta Issue Atomizer; Extend and
// Summarize have no counterpart there and stay on ATOMIZER_BASE_URL.
atomClient.UseRemote(cfg.AtomizerRemoteBaseURL, cfg.AtomizerRemoteAPIKey, cfg.AtomizerTimeout)
log.Info("atomizer: subdivide served by remote backend",
"url", cfg.AtomizerRemoteBaseURL, "extend_summarize", cfg.AtomizerBaseURL)
}
srv.SetAtomizerInfo(atomClient)
srv.AddReadinessCheck(httpx.ReadinessCheck{Name: "atomizer", Probe: atomClient.Healthy})
@@ -107,7 +116,7 @@ func run() error {
// Background job queue + atomization handlers.
runner := jobs.NewRunner(st, log, reg, 4)
atomSvc := atomize.NewService(st, atomClient, log, cfg.AppBaseURL,
atomSvc := atomize.NewService(st, atomClient, log, cfg.AppInternalURL,
[]byte(cfg.SessionSecret), srv.Publish)
runner.Register(atomize.JobKindSubdivide, atomSvc.HandleSubdivide)
runner.Register(atomize.JobKindExtend, atomSvc.HandleExtend)
+57 -4
View File
@@ -13,7 +13,8 @@ services:
# Mongo is reachable only on the compose network; never published to the
# host (§12).
volumes:
- mongo-data:/data/db
# data lives in-repo (gitignored, dot-prefixed so go tooling skips it)
- ./.volumes/mongo:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
@@ -27,8 +28,16 @@ services:
env_file: .env
environment:
MONGO_URI: mongodb://mongo:27017
# in-network address handed to the external services for callbacks
# and signed attachment URLs (§5.2 example: http://app:8787/…)
APP_INTERNAL_URL: http://app:8787
# NOTE: published on all interfaces to match this host's pattern (gitea,
# outline, wekan are exposed the same way; docker-published ports bypass
# UFW). The spec-default loopback-only binding is the commented line —
# restore it once the app sits behind the reverse proxy exclusively.
ports:
- "127.0.0.1:${APP_PORT:-8787}:8787"
- "${APP_PORT:-8787}:8787"
# - "127.0.0.1:${APP_PORT:-8787}:8787"
depends_on:
mongo:
condition: service_healthy
@@ -39,5 +48,49 @@ services:
retries: 3
start_period: 10s
volumes:
mongo-data:
# --- placeholder external services (docker compose --profile mocks up) ---
# Two fully independent services: separate builds, ports, tokens (§5).
atomizer-mock:
build: ./services/atomizer
profiles: ["mocks"]
restart: unless-stopped
environment:
PORT: "8090"
ATOMIZER_TOKEN: ${ATOMIZER_TOKEN}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
ANTHROPIC_OPENAI_BASE_URL: ${ANTHROPIC_OPENAI_BASE_URL:-https://api.anthropic.com/v1}
ANTHROPIC_MODEL: ${ANTHROPIC_MODEL:-claude-sonnet-4-6}
LLM_API_STYLE: ${LLM_API_STYLE:-openai}
ports:
- "127.0.0.1:8090:8090"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8090/healthz"]
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
work-performer:
build: ./services/work-performer
profiles: ["mocks"]
restart: unless-stopped
environment:
PORT: "8091"
WORK_PERFORMER_TOKEN: ${WORK_PERFORMER_TOKEN}
PUBLIC_BASE_URL: http://work-performer:8091
# host Claude Code config/secrets (§9.2); mounted into the node user's
# home because claude refuses --dangerously-skip-permissions as root
volumes:
- ${HOME}/.claude:/home/node/.claude
- ${HOME}/.claude.json:/home/node/.claude.json
- ./.volumes/work:/work
ports:
- "127.0.0.1:8091:8091"
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8091/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
+287
View File
@@ -0,0 +1,287 @@
package atomize
import (
"context"
"fmt"
"math"
"net/http"
"strings"
"time"
"unicode/utf8"
"bountyboard/internal/extsvc"
)
// Backend for the Anypreta "Issue Atomizer" API (OpenAPI 3.1, v0.1.0). Its
// model is two-level — issue → features → tasks — where ours is one-level
// (task → children), and only *features* carry an effort weight
// (`estimation`); its tasks carry none at all. A board child therefore maps to
// a Feature and Subdivide calls POST /v1/atomize-issue.
//
// Extend and Summarize have no counterpart here (/v1/extend-feature-tasks adds
// tasks *inside* one feature and returns the whole set), so those stay on the
// §5.1 service. The API declares no security scheme; it authenticates with
// Authorization: Bearer <key>.
// Field caps from the spec — exceeded values are 422s, so trim rather than fail.
const (
maxIssueLen = 50000
maxGuidanceLen = 10000
maxSystemIDLen = 256
maxSystemDesc = 2000
)
type apComponent struct {
ID string `json:"id"`
Description string `json:"description"`
SubComponents []apComponent `json:"sub_components,omitempty"`
}
type apSystem struct {
ID string `json:"id"`
Description string `json:"description"`
Components []apComponent `json:"components,omitempty"`
}
type apAtomizeIssueRequest struct {
System apSystem `json:"system"`
Issue string `json:"issue"`
GuidanceNote string `json:"guidance_note,omitempty"`
}
type apCriterion struct {
Description string `json:"description"`
}
type apFeature struct {
ID string `json:"id"`
Title string `json:"title"`
StakeholderSummary string `json:"stakeholder_summary"`
Description string `json:"description"`
AcceptanceCriteria []apCriterion `json:"acceptance_criteria"`
Priority int `json:"priority"`
RelatedComponents []string `json:"related_components"`
DependsOn []string `json:"depends_on"`
// Estimation is optional and nullable in the spec; observed to sum to 1
// across a feature set, but that is not promised.
Estimation *float64 `json:"estimation"`
}
type apAtomizeIssueResponse struct {
FeatureSet struct {
Features []apFeature `json:"features"`
} `json:"feature_set"`
}
// remote is the Anypreta-backed Atomize path. It reuses extsvc for the bearer
// auth, retry and circuit breaker; the base URL carries the /v1 prefix so
// extsvc's /healthz probe lands on the API's /v1/healthz.
type remote struct {
c *extsvc.Client
}
func newRemote(baseURL, apiKey string, timeout time.Duration) *remote {
base := strings.TrimRight(baseURL, "/") + "/v1"
return &remote{c: extsvc.NewClient("atomizer-remote",
func() string { return base }, apiKey, timeout)}
}
// Atomize maps a subdivide request onto POST /v1/atomize-issue and maps the
// returned features back onto board children.
func (r *remote) Atomize(ctx context.Context, req AtomizeRequest) (*AtomizeResponse, error) {
body := apAtomizeIssueRequest{
System: systemFor(req),
Issue: truncate(issueText(req), maxIssueLen),
GuidanceNote: truncate(guidanceNote(req), maxGuidanceLen),
}
var resp apAtomizeIssueResponse
if err := r.c.Call(ctx, http.MethodPost, "/atomize-issue", body, &resp); err != nil {
return nil, err
}
features := resp.FeatureSet.Features
if len(features) == 0 {
return nil, fmt.Errorf("%w: empty feature set", ErrBadCoefficients)
}
coeffs, estimated, err := featureCoefficients(features)
if err != nil {
return nil, err
}
tasks := make([]SubTask, len(features))
for i, f := range features {
criteria := make([]string, 0, len(f.AcceptanceCriteria))
for _, c := range f.AcceptanceCriteria {
if d := strings.TrimSpace(c.Description); d != "" {
criteria = append(criteria, d)
}
}
tasks[i] = SubTask{
Title: strings.TrimSpace(f.Title),
Description: strings.TrimSpace(f.Description),
AcceptanceCriteria: criteria,
EffortCoefficient: coeffs[i],
}
}
out := &AtomizeResponse{Tasks: tasks, Model: "anypreta/issue-atomizer"}
if !estimated {
out.Notes = "The atomizer returned no effort estimation, so the budget was split evenly across the tasks."
}
return out, nil
}
// featureCoefficients turns feature estimations into effort coefficients
// summing to exactly 1. Estimations are optional in the spec, so an incomplete
// set falls back to an even split (reported via the second return value)
// rather than failing the job — bounties still need *some* weight.
func featureCoefficients(features []apFeature) ([]float64, bool, error) {
raw := make([]float64, len(features))
estimated := true
for i, f := range features {
if f.Estimation == nil || *f.Estimation <= 0 || math.IsNaN(*f.Estimation) {
estimated = false
break
}
raw[i] = *f.Estimation
}
if !estimated {
even := 1.0 / float64(len(features))
for i := range raw {
raw[i] = even
}
}
norm, err := normalizeToOne(raw)
if err != nil {
return nil, false, err
}
return norm, estimated, nil
}
// normalizeToOne rescales positive weights proportionally so they sum to
// exactly 1. Unlike NormalizeCoefficients (§5.1) it accepts any positive sum:
// this API never promises a normalized feature set, so a deviation is expected
// rather than a service fault.
func normalizeToOne(weights []float64) ([]float64, error) {
if len(weights) == 0 {
return nil, fmt.Errorf("%w: empty", ErrBadCoefficients)
}
sum := 0.0
for _, w := range weights {
if math.IsNaN(w) || math.IsInf(w, 0) || w <= 0 {
return nil, fmt.Errorf("%w: weight %v is not positive", ErrBadCoefficients, w)
}
sum += w
}
out := make([]float64, len(weights))
largest, total := 0, 0.0
for i, w := range weights {
out[i] = math.Round(w/sum*10000) / 10000
total += out[i]
if out[i] > out[largest] {
largest = i
}
}
out[largest] = math.Round((out[largest]+1-total)*10000) / 10000
return out, nil
}
// defaultComponents is the component tree we send for every customer.
//
// The spec marks `components` optional, but the service only emits features for
// components it considers *in scope*: an empty tree yields zero features and a
// 422 ("no in-scope components produced features"). The board has no component
// model of its own, so we send a generic tree covering the usual surfaces of a
// software change. Components irrelevant to a ticket simply produce nothing, so
// a broad tree costs nothing and a narrow one under-splits — a single catch-all
// component collapses every ticket into one feature.
var defaultComponents = []apComponent{
{ID: "frontend", Description: "User interface: pages, views, client-side behaviour and styling."},
{ID: "backend", Description: "Server-side application logic, APIs and background jobs."},
{ID: "data", Description: "Data storage: schema, migrations, queries and data integrity."},
{ID: "integrations", Description: "Integrations with external systems, third-party APIs and notifications."},
}
// systemFor derives the required system object from the customer, which is the
// only project-level context the board has.
func systemFor(req AtomizeRequest) apSystem {
id := strings.TrimSpace(req.CustomerID)
if id == "" {
id = "bountyboard"
}
name := strings.TrimSpace(req.CustomerName)
if name == "" {
name = "this project"
}
return apSystem{
ID: truncate(id, maxSystemIDLen),
Description: truncate(fmt.Sprintf("Software project %q. Work is delivered as small, independently deliverable tasks picked up by freelance developers.", name), maxSystemDesc),
Components: defaultComponents,
}
}
// issueText flattens the ticket into the single `issue` string the API takes —
// it has no fields for acceptance criteria, links or attachments.
func issueText(req AtomizeRequest) string {
var b strings.Builder
if t := strings.TrimSpace(req.Title); t != "" {
fmt.Fprintf(&b, "%s\n\n", t)
}
if d := strings.TrimSpace(req.Description); d != "" {
fmt.Fprintf(&b, "%s\n", d)
}
if len(req.AcceptanceCriteria) > 0 {
b.WriteString("\nAcceptance criteria:\n")
for _, c := range req.AcceptanceCriteria {
fmt.Fprintf(&b, "- %s\n", c)
}
}
if len(req.Links) > 0 {
b.WriteString("\nLinks:\n")
for _, l := range req.Links {
fmt.Fprintf(&b, "- %s\n", l)
}
}
if len(req.Attachments) > 0 {
b.WriteString("\nAttachments:\n")
for _, a := range req.Attachments {
fmt.Fprintf(&b, "- %s (%s): %s\n", a.Name, a.MimeType, a.URL)
}
}
s := strings.TrimSpace(b.String())
if s == "" {
s = "(no description provided)"
}
return s
}
// guidanceNote carries the consultant's note plus our task-count constraints,
// which the API has no dedicated field for.
func guidanceNote(req AtomizeRequest) string {
parts := make([]string, 0, 2)
if n := strings.TrimSpace(req.SubdivisionNote); n != "" {
parts = append(parts, n)
}
if c := req.Constraints; c != nil {
switch {
case c.MinTasks > 0 && c.MaxTasks > 0:
parts = append(parts, fmt.Sprintf("Produce between %d and %d features.", c.MinTasks, c.MaxTasks))
case c.MinTasks > 0:
parts = append(parts, fmt.Sprintf("Produce at least %d features.", c.MinTasks))
case c.MaxTasks > 0:
parts = append(parts, fmt.Sprintf("Produce at most %d features.", c.MaxTasks))
}
}
return strings.Join(parts, "\n\n")
}
// truncate caps a string at max bytes without splitting a rune. Byte length is
// a safe proxy for the spec's character limits: it is never smaller.
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
cut := max
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut]
}
+203
View File
@@ -0,0 +1,203 @@
package atomize
import (
"encoding/json"
"math"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func ptr(f float64) *float64 { return &f }
func sum(xs []float64) float64 {
t := 0.0
for _, x := range xs {
t += x
}
return t
}
func TestFeatureCoefficientsUsesEstimations(t *testing.T) {
// The shape the live API returns: estimations already summing to 1.
got, estimated, err := featureCoefficients([]apFeature{
{Estimation: ptr(0.3636363636)},
{Estimation: ptr(0.6363636364)},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !estimated {
t.Fatal("expected estimations to be used")
}
if math.Abs(sum(got)-1) > 1e-9 {
t.Fatalf("coefficients must sum to exactly 1, got %v (sum %v)", got, sum(got))
}
if math.Abs(got[0]-0.3636) > 1e-9 {
t.Fatalf("estimation not preserved: %v", got)
}
}
func TestFeatureCoefficientsRescalesUnnormalized(t *testing.T) {
// Nothing in the spec promises a normalized feature set.
got, estimated, err := featureCoefficients([]apFeature{
{Estimation: ptr(0.2)}, {Estimation: ptr(0.2)}, {Estimation: ptr(0.4)},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !estimated {
t.Fatal("expected estimations to be used")
}
if math.Abs(sum(got)-1) > 1e-9 {
t.Fatalf("want sum 1, got %v (sum %v)", got, sum(got))
}
if math.Abs(got[2]-0.5) > 1e-9 {
t.Fatalf("want proportional rescale (0.25/0.25/0.5), got %v", got)
}
}
func TestFeatureCoefficientsFallsBackToEvenSplit(t *testing.T) {
// estimation is optional and nullable — a missing one must not fail the
// job, and must be reported so the consultant knows the split is not real.
got, estimated, err := featureCoefficients([]apFeature{
{Estimation: ptr(0.5)}, {Estimation: nil}, {Estimation: ptr(0.2)},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if estimated {
t.Fatal("expected fallback to even split when an estimation is missing")
}
if math.Abs(sum(got)-1) > 1e-9 {
t.Fatalf("want sum 1, got %v (sum %v)", got, sum(got))
}
for _, c := range got {
if math.Abs(c-1.0/3.0) > 1e-3 {
t.Fatalf("want even split, got %v", got)
}
}
}
func TestNormalizeToOneRejectsNonPositive(t *testing.T) {
for _, bad := range [][]float64{{0.5, 0}, {0.5, -0.1}, {math.NaN()}, {}} {
if _, err := normalizeToOne(bad); err == nil {
t.Fatalf("want error for %v", bad)
}
}
}
func TestIssueTextCarriesFieldsTheAPILacks(t *testing.T) {
// The API takes a single `issue` string: criteria, links and attachments
// have no fields of their own and must survive in the text.
got := issueText(AtomizeRequest{
Title: "Add CSV export",
Description: "Customers cannot export.",
AcceptanceCriteria: []string{"Downloads a .csv"},
Links: []string{"https://tracker/1"},
Attachments: []Attachment{{Name: "mock.png", MimeType: "image/png", URL: "https://app/f/1"}},
})
for _, want := range []string{"Add CSV export", "Customers cannot export.", "Downloads a .csv", "https://tracker/1", "mock.png", "https://app/f/1"} {
if !strings.Contains(got, want) {
t.Errorf("issue text lost %q:\n%s", want, got)
}
}
}
func TestGuidanceNoteFoldsInConstraints(t *testing.T) {
got := guidanceNote(AtomizeRequest{
SubdivisionNote: "Keep it small.",
Constraints: &Constraints{MinTasks: 2, MaxTasks: 5},
})
if !strings.Contains(got, "Keep it small.") || !strings.Contains(got, "between 2 and 5") {
t.Fatalf("want note + constraints, got %q", got)
}
}
func TestTruncateDoesNotSplitRunes(t *testing.T) {
if got := truncate("héllo", 2); got != "h" {
t.Fatalf("want %q, got %q", "h", got)
}
}
// Exercises the full remote path against a stub speaking the Anypreta wire
// format, asserting both the request we send and the children we build.
func TestRemoteAtomizeMapsFeaturesToChildren(t *testing.T) {
var got apAtomizeIssueRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/atomize-issue" {
t.Errorf("wrong path: %s", r.URL.Path)
}
if auth := r.Header.Get("Authorization"); auth != "Bearer k" {
t.Errorf("wrong auth header: %q", auth)
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("decode: %v", err)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"feature_set":{"features":[
{"id":"f1","title":"UI","stakeholder_summary":"s","description":"d1",
"acceptance_criteria":[{"description":"c1"},{"description":"c2"}],
"priority":1,"related_components":["web"],"estimation":0.25},
{"id":"f2","title":"API","stakeholder_summary":"s","description":"d2",
"acceptance_criteria":[{"description":"c3"}],
"priority":2,"related_components":["api"],"estimation":0.75}]}}`))
}))
defer srv.Close()
resp, err := newRemote(srv.URL, "k", 5*time.Second).Atomize(t.Context(), AtomizeRequest{
TaskID: "t1", Title: "Export", Description: "desc",
CustomerID: "cust1", CustomerName: "Acme",
})
if err != nil {
t.Fatalf("Atomize: %v", err)
}
if got.System.ID != "cust1" || !strings.Contains(got.System.Description, "Acme") {
t.Errorf("system not derived from customer: %+v", got.System)
}
// An empty component tree makes the live service return zero features
// (422), so never let the tree go missing.
if len(got.System.Components) == 0 {
t.Error("system.components must not be empty: the service only emits features for in-scope components")
}
if len(resp.Tasks) != 2 {
t.Fatalf("want 2 children, got %d", len(resp.Tasks))
}
if resp.Tasks[0].Title != "UI" || resp.Tasks[0].Description != "d1" {
t.Errorf("bad child mapping: %+v", resp.Tasks[0])
}
if len(resp.Tasks[0].AcceptanceCriteria) != 2 || resp.Tasks[0].AcceptanceCriteria[0] != "c1" {
t.Errorf("acceptance criteria not mapped: %+v", resp.Tasks[0].AcceptanceCriteria)
}
if resp.Tasks[0].EffortCoefficient != 0.25 || resp.Tasks[1].EffortCoefficient != 0.75 {
t.Errorf("estimation not mapped to effort coefficient: %v / %v",
resp.Tasks[0].EffortCoefficient, resp.Tasks[1].EffortCoefficient)
}
if resp.Notes != "" {
t.Errorf("no even-split note expected when estimations are present: %q", resp.Notes)
}
}
// A remote-backed client must still serve Extend and Summarize from the §5.1
// service — the Anypreta API has no counterpart for either.
func TestRemoteDoesNotHijackExtend(t *testing.T) {
var hit string
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hit = r.URL.Path
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"task":{"title":"t","description":"d","acceptanceCriteria":[],"effortCoefficient":0.5}}`))
}))
defer mock.Close()
c := New(func() string { return mock.URL }, "tok", 5*time.Second)
c.UseRemote("http://remote.invalid", "k", 5*time.Second)
if _, err := c.Extend(t.Context(), ExtendRequest{TaskID: "t1", ExtensionNote: "more"}); err != nil {
t.Fatalf("Extend: %v", err)
}
if hit != "/v1/extend" {
t.Fatalf("Extend must go to the §5.1 service, hit %q", hit)
}
}
+45 -3
View File
@@ -33,6 +33,11 @@ type AtomizeRequest struct {
Links []string `json:"links"`
SubdivisionNote string `json:"subdivisionNote,omitempty"`
Constraints *Constraints `json:"constraints,omitempty"`
// CustomerID / CustomerName describe the project the ticket belongs to.
// The §5.1 service ignores them; the Anypreta backend requires them to
// build its mandatory `system` object.
CustomerID string `json:"customerId,omitempty"`
CustomerName string `json:"customerName,omitempty"`
}
type ExtendRequest struct {
@@ -70,6 +75,10 @@ var ErrBadCoefficients = errors.New("atomizer returned invalid effort coefficien
type Client struct {
c *extsvc.Client
// remote, when set, serves Atomize from the Anypreta Issue Atomizer
// instead of the §5.1 service. Extend and Summarize have no counterpart
// there and always go to c. See anypreta.go.
remote *remote
}
// New builds the client. baseURL is resolved per call so the admin settings
@@ -78,13 +87,46 @@ func New(baseURL func() string, token string, timeout time.Duration) *Client {
return &Client{c: extsvc.NewClient("atomizer", baseURL, token, timeout)}
}
func (a *Client) BreakerState() string { return a.c.BreakerState() }
func (a *Client) Healthy(ctx context.Context) error { return a.c.Healthy(ctx) }
// UseRemote routes Atomize to the Anypreta Issue Atomizer at baseURL.
func (a *Client) UseRemote(baseURL, apiKey string, timeout time.Duration) {
a.remote = newRemote(baseURL, apiKey, timeout)
}
// RemoteEnabled reports whether Subdivide is served by the Anypreta backend.
func (a *Client) RemoteEnabled() bool { return a.remote != nil }
func (a *Client) BreakerState() string {
if a.remote != nil {
if s := a.remote.c.BreakerState(); s != "closed" {
return s
}
}
return a.c.BreakerState()
}
// Healthy probes every backend an atomization can touch, so readiness and the
// admin service panel go degraded if either half is down.
func (a *Client) Healthy(ctx context.Context) error {
if err := a.c.Healthy(ctx); err != nil {
return err
}
if a.remote != nil {
if err := a.remote.c.Healthy(ctx); err != nil {
return fmt.Errorf("anypreta atomizer: %w", err)
}
}
return nil
}
// Atomize calls POST /v1/atomize and applies the §5.1 contract: coefficients
// must sum to 1 ± 0.001; a deviation ≤ 0.05 is defensively renormalized,
// anything worse is rejected as a service error.
// anything worse is rejected as a service error. With a remote backend
// configured the call is served by the Anypreta API instead, which normalizes
// its own feature estimations.
func (a *Client) Atomize(ctx context.Context, req AtomizeRequest) (*AtomizeResponse, error) {
if a.remote != nil {
return a.remote.Atomize(ctx, req)
}
var resp AtomizeResponse
if err := a.c.Call(ctx, http.MethodPost, "/v1/atomize", req, &resp); err != nil {
return nil, err
+9
View File
@@ -99,6 +99,15 @@ func (s *Service) HandleSubdivide(ctx context.Context, job *jobs.Job) error {
Attachments: s.requestAttachments(t),
Links: taskLinks(t),
SubdivisionNote: p.Note,
CustomerID: t.CustomerID,
}
// Project context for backends that need it (the Anypreta `system`
// object). Not worth failing an atomization over.
if cust, err := s.st.CustomerByID(ctx, t.CustomerID); err == nil {
req.CustomerName = cust.Name
} else {
s.log.Warn("subdivide: customer lookup failed, sending id only",
"task", t.ID, "customer", t.CustomerID, "err", err)
}
if p.MinTasks > 0 || p.MaxTasks > 0 {
req.Constraints = &Constraints{MinTasks: p.MinTasks, MaxTasks: p.MaxTasks}
+43
View File
@@ -0,0 +1,43 @@
package auth
import (
"fmt"
"net/smtp"
"strings"
"bountyboard/internal/config"
)
// Mailer sends plain-text email via SMTP (net/smtp, STARTTLS when offered).
// All features depending on it stay disabled until SMTP_HOST is set (§7).
type Mailer struct {
cfg config.SMTP
}
func NewMailer(cfg config.SMTP) *Mailer { return &Mailer{cfg: cfg} }
func (m *Mailer) Enabled() bool { return m.cfg.Enabled() }
func (m *Mailer) Send(to, subject, body string) error {
if !m.Enabled() {
return fmt.Errorf("smtp is not configured")
}
addr := fmt.Sprintf("%s:%d", m.cfg.Host, m.cfg.Port)
msg := strings.Join([]string{
"From: " + m.cfg.From,
"To: " + to,
"Subject: " + subject,
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=utf-8",
"",
body,
}, "\r\n")
var a smtp.Auth
if m.cfg.User != "" {
a = smtp.PlainAuth("", m.cfg.User, m.cfg.Password, m.cfg.Host)
}
if err := smtp.SendMail(addr, a, m.cfg.From, []string{to}, []byte(msg)); err != nil {
return fmt.Errorf("smtp send: %w", err)
}
return nil
}
+82 -4
View File
@@ -31,10 +31,10 @@ func SanitizeHTML(in string) string {
if c != '<' {
j := strings.IndexByte(in[i:], '<')
if j < 0 {
out.WriteString(html.EscapeString(in[i:]))
out.WriteString(escapeText(in[i:]))
break
}
out.WriteString(html.EscapeString(in[i : i+j]))
out.WriteString(escapeText(in[i : i+j]))
i += j
continue
}
@@ -53,6 +53,31 @@ func SanitizeHTML(in string) string {
name, attrs := splitTag(raw)
name = strings.ToLower(name)
// @-mention tokens: <span class="mention" data-user-card="ULID">@Name</span>.
// Only this exact shape is allowed; any other span is dropped.
if name == "span" {
if closing {
for n := len(stack) - 1; n >= 0; n-- {
if stack[n] == "span" {
for len(stack) > n {
top := stack[len(stack)-1]
stack = stack[:len(stack)-1]
out.WriteString("</" + top + ">")
}
break
}
}
continue
}
uid := mentionUID(attrs)
if uid == "" {
continue // drop the opening span, keep its text
}
out.WriteString(`<span class="mention" data-user-card="` + html.EscapeString(uid) + `">`)
stack = append(stack, "span")
continue
}
if !allowedTags[name] {
continue // drop the tag entirely, keep surrounding text
}
@@ -93,6 +118,15 @@ func SanitizeHTML(in string) string {
return out.String()
}
// escapeText normalizes a text run from (possibly entity-encoded) input HTML:
// decode existing entities, then re-escape. This keeps a single round of
// escaping so entities the browser emits — e.g. &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.
func splitTag(raw string) (string, string) {
for i := 0; i < len(raw); i++ {
@@ -104,6 +138,50 @@ func splitTag(raw string) (string, string) {
return raw, ""
}
// mentionUID validates a mention span's attributes and returns the referenced
// user id (alphanumeric, ULID-shaped), or "" if the span is not a valid
// mention.
func mentionUID(attrs string) string {
low := strings.ToLower(attrs)
if !strings.Contains(low, "mention") {
return ""
}
idx := strings.Index(low, "data-user-card")
if idx < 0 {
return ""
}
rest := strings.TrimSpace(attrs[idx+len("data-user-card"):])
if !strings.HasPrefix(rest, "=") {
return ""
}
rest = strings.TrimSpace(rest[1:])
var val string
switch {
case strings.HasPrefix(rest, `"`):
if e := strings.IndexByte(rest[1:], '"'); e >= 0 {
val = rest[1 : 1+e]
}
case strings.HasPrefix(rest, "'"):
if e := strings.IndexByte(rest[1:], '\''); e >= 0 {
val = rest[1 : 1+e]
}
default:
val = rest
if sp := strings.IndexAny(val, " \t"); sp >= 0 {
val = val[:sp]
}
}
if val == "" || len(val) > 32 {
return ""
}
for _, r := range val {
if !((r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
return ""
}
}
return val
}
// safeHref extracts href and accepts only http, https, or mailto schemes.
func safeHref(attrs string) string {
lower := strings.ToLower(attrs)
@@ -150,10 +228,10 @@ func SanitizePlain(in string) string {
if in[i] != '<' {
j := strings.IndexByte(in[i:], '<')
if j < 0 {
out.WriteString(html.EscapeString(in[i:]))
out.WriteString(escapeText(in[i:]))
break
}
out.WriteString(html.EscapeString(in[i : i+j]))
out.WriteString(escapeText(in[i : i+j]))
i += j
continue
}
+4
View File
@@ -12,6 +12,10 @@ func TestSanitizeHTML(t *testing.T) {
want string
}{
{"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>"},
{"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>"},
+28 -8
View File
@@ -36,7 +36,11 @@ func (o OIDC) Enabled() bool {
}
type Config struct {
AppBaseURL string
AppBaseURL string
// AppInternalURL is how the external services reach the app from inside
// the compose network (callback URLs, signed attachment URLs). Defaults
// to AppBaseURL.
AppInternalURL string
TrustedProxyCIDRs []netip.Prefix
AppPort int
MongoURI string
@@ -50,9 +54,14 @@ type Config struct {
AdminEmail string
AdminInitialPassword string
AtomizerBaseURL string
AtomizerToken string
AtomizerTimeout time.Duration
AtomizerBaseURL string
AtomizerToken string
AtomizerTimeout time.Duration
// AtomizerRemoteBaseURL / AtomizerRemoteAPIKey enable the Anypreta Issue
// Atomizer as the Subdivide backend. Both must be set; Extend and
// Summarize stay on ATOMIZER_BASE_URL either way.
AtomizerRemoteBaseURL string
AtomizerRemoteAPIKey string
WorkPerformerBaseURL string
WorkPerformerToken string
WorkPerformerHTTPTimeout time.Duration
@@ -111,10 +120,12 @@ func Load() (*Config, error) {
AdminEmail: strings.ToLower(strings.TrimSpace(getenv("ADMIN_EMAIL", ""))),
AdminInitialPassword: os.Getenv("ADMIN_INITIAL_PASSWORD"),
AtomizerBaseURL: strings.TrimRight(getenv("ATOMIZER_BASE_URL", "http://atomizer-mock:8090"), "/"),
AtomizerToken: os.Getenv("ATOMIZER_TOKEN"),
WorkPerformerBaseURL: strings.TrimRight(getenv("WORK_PERFORMER_BASE_URL", "http://work-performer:8091"), "/"),
WorkPerformerToken: os.Getenv("WORK_PERFORMER_TOKEN"),
AtomizerBaseURL: strings.TrimRight(getenv("ATOMIZER_BASE_URL", "http://atomizer-mock:8090"), "/"),
AtomizerToken: os.Getenv("ATOMIZER_TOKEN"),
AtomizerRemoteBaseURL: strings.TrimRight(os.Getenv("ATOMIZER_REMOTE_BASE_URL"), "/"),
AtomizerRemoteAPIKey: os.Getenv("ATOMIZER_REMOTE_API_KEY"),
WorkPerformerBaseURL: strings.TrimRight(getenv("WORK_PERFORMER_BASE_URL", "http://work-performer:8091"), "/"),
WorkPerformerToken: os.Getenv("WORK_PERFORMER_TOKEN"),
SMTP: SMTP{
Host: os.Getenv("SMTP_HOST"),
@@ -132,6 +143,7 @@ func Load() (*Config, error) {
CookieSecureMode: getenv("COOKIE_SECURE", "auto"),
AtomizeMaxConcurrency: atoi("ATOMIZE_MAX_CONCURRENCY", "3"),
}
c.AppInternalURL = strings.TrimRight(getenv("APP_INTERNAL_URL", c.AppBaseURL), "/")
c.AtomizerTimeout = time.Duration(atoi("ATOMIZER_TIMEOUT_SEC", "120")) * time.Second
c.WorkPerformerHTTPTimeout = time.Duration(atoi("WORK_PERFORMER_HTTP_TIMEOUT_SEC", "30")) * time.Second
@@ -150,6 +162,14 @@ func Load() (*Config, error) {
fail("%s: %q is not an absolute URL", name, v)
}
}
if c.AtomizerRemoteBaseURL != "" {
if u, err := url.Parse(c.AtomizerRemoteBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
fail("ATOMIZER_REMOTE_BASE_URL: %q is not an absolute URL", c.AtomizerRemoteBaseURL)
}
if c.AtomizerRemoteAPIKey == "" {
fail("ATOMIZER_REMOTE_API_KEY: required when ATOMIZER_REMOTE_BASE_URL is set")
}
}
if len(c.SessionSecret) < 16 {
fail("SESSION_SECRET: must be at least 16 characters of random data")
}
+258
View File
@@ -0,0 +1,258 @@
//go:build contract
// Contract tests for the two mock services (§13). They run against live
// containers:
//
// docker compose --profile mocks -f docker-compose.yml -f docker-compose.test.yml up -d --build
// ATOMIZER_TOKEN=… WORK_PERFORMER_TOKEN=… go test -tags=contract ./internal/contract/
//
// URLs default to the loopback ports published by the mocks profile.
package contract
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math"
"net"
"net/http"
"os"
"strings"
"testing"
"time"
"bountyboard/internal/workperform"
)
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
var (
atomizerURL = env("CONTRACT_ATOMIZER_URL", "http://127.0.0.1:8090")
performerURL = env("CONTRACT_PERFORMER_URL", "http://127.0.0.1:8091")
atomizerToken = os.Getenv("ATOMIZER_TOKEN")
performerToken = os.Getenv("WORK_PERFORMER_TOKEN")
)
func postJSON(t *testing.T, url, token string, body any) (*http.Response, []byte) {
t.Helper()
b, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST %s: %v", url, err)
}
data, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return resp, data
}
func TestAtomizerContract(t *testing.T) {
// healthz
resp, err := http.Get(atomizerURL + "/healthz")
if err != nil {
t.Skipf("atomizer not reachable at %s (start the mocks profile): %v", atomizerURL, err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("healthz: %d", resp.StatusCode)
}
// wrong bearer token rejected (when a token is configured)
if atomizerToken != "" {
r, _ := postJSON(t, atomizerURL+"/v1/atomize", "wrong-token", map[string]any{"taskId": "x", "title": "x"})
if r.StatusCode != http.StatusUnauthorized {
t.Fatalf("wrong token: %d, want 401", r.StatusCode)
}
}
// atomize honors the §5.1 response contract
r, data := postJSON(t, atomizerURL+"/v1/atomize", atomizerToken, map[string]any{
"taskId": "01JCONTRACT",
"title": "Implement user import",
"description": "Import users from CSV with mapping and validation.",
"acceptanceCriteria": []string{"valid rows imported", "errors reported per row"},
"constraints": map[string]int{"minTasks": 2, "maxTasks": 5},
})
if r.StatusCode != http.StatusOK {
t.Fatalf("atomize: %d %s", r.StatusCode, data)
}
var out struct {
Tasks []struct {
Title string `json:"title"`
Description string `json:"description"`
EffortCoefficient float64 `json:"effortCoefficient"`
} `json:"tasks"`
Model string `json:"model"`
}
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("decode: %v: %s", err, data)
}
if len(out.Tasks) < 2 || len(out.Tasks) > 5 {
t.Fatalf("task count %d outside constraints", len(out.Tasks))
}
sum := 0.0
for _, task := range out.Tasks {
if task.Title == "" || task.EffortCoefficient <= 0 || task.EffortCoefficient > 1 {
t.Fatalf("bad task: %+v", task)
}
sum += task.EffortCoefficient
}
if math.Abs(sum-1) > 0.001 {
t.Fatalf("coefficient sum %v, want 1±0.001", sum)
}
// extend returns exactly one task with coefficient in (0, 2]
r, data = postJSON(t, atomizerURL+"/v1/extend", atomizerToken, map[string]any{
"taskId": "01JCONTRACT", "title": "Implement user import",
"description": "Import users from CSV.",
"extensionNote": "add reusable column-mapping presets",
})
if r.StatusCode != http.StatusOK {
t.Fatalf("extend: %d %s", r.StatusCode, data)
}
var ext struct {
Task struct {
Title string `json:"title"`
EffortCoefficient float64 `json:"effortCoefficient"`
} `json:"task"`
}
if err := json.Unmarshal(data, &ext); err != nil {
t.Fatalf("decode extend: %v", err)
}
if ext.Task.Title == "" || ext.Task.EffortCoefficient <= 0 || ext.Task.EffortCoefficient > 2 {
t.Fatalf("bad extension: %+v", ext.Task)
}
// extend without a note is rejected
r, _ = postJSON(t, atomizerURL+"/v1/extend", atomizerToken, map[string]any{
"taskId": "x", "title": "x",
})
if r.StatusCode != http.StatusBadRequest {
t.Fatalf("extend without note: %d, want 400", r.StatusCode)
}
}
func TestWorkPerformerContract(t *testing.T) {
resp, err := http.Get(performerURL + "/healthz")
if err != nil {
t.Skipf("work performer not reachable at %s (start the mocks profile): %v", performerURL, err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("healthz: %d", resp.StatusCode)
}
// callback receiver on the host, reachable from the container via
// host.docker.internal (extra_hosts: host-gateway)
callbackCh := make(chan struct {
body []byte
sig string
}, 1)
ln, err := net.Listen("tcp", "0.0.0.0:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
go http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
select {
case callbackCh <- struct {
body []byte
sig string
}{body, r.Header.Get("X-Signature")}:
default:
}
w.WriteHeader(200)
}))
port := ln.Addr().(*net.TCPAddr).Port
callbackURL := fmt.Sprintf("http://host.docker.internal:%d/cb", port)
r, data := postJSON(t, performerURL+"/v1/jobs", performerToken, map[string]any{
"taskId": "01JCONTRACTWP", "title": "Write hello world",
"description": "Create hello.txt containing hello world.",
"acceptanceCriteria": []string{"hello.txt exists"},
"callbackUrl": callbackURL,
})
if r.StatusCode != http.StatusAccepted {
t.Fatalf("submit: %d %s", r.StatusCode, data)
}
var accepted struct {
JobID string `json:"jobId"`
Status string `json:"status"`
}
if err := json.Unmarshal(data, &accepted); err != nil || accepted.JobID == "" {
t.Fatalf("bad 202 body: %s", data)
}
// status endpoint
req, _ := http.NewRequest(http.MethodGet, performerURL+"/v1/jobs/"+accepted.JobID, nil)
req.Header.Set("Authorization", "Bearer "+performerToken)
sResp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
sBody, _ := io.ReadAll(sResp.Body)
sResp.Body.Close()
var status struct {
Status string `json:"status"`
}
if err := json.Unmarshal(sBody, &status); err != nil {
t.Fatalf("status decode: %s", sBody)
}
if !strings.Contains("queued running succeeded failed", status.Status) {
t.Fatalf("status %q", status.Status)
}
// HMAC-signed callback arrives (the simulated path is fast; the real
// claude path may take minutes — allow a generous window)
select {
case cb := <-callbackCh:
if !workperform.VerifySignature(performerToken, cb.body, cb.sig) {
t.Fatalf("callback signature invalid")
}
var payload workperform.Callback
if err := json.Unmarshal(cb.body, &payload); err != nil {
t.Fatalf("callback decode: %v", err)
}
if payload.JobID != accepted.JobID || payload.TaskID != "01JCONTRACTWP" {
t.Fatalf("callback ids: %+v", payload)
}
if payload.Status != "succeeded" && payload.Status != "failed" {
t.Fatalf("callback status %q", payload.Status)
}
// artifacts must be fetchable (rewrite in-network host for the host-side test)
for _, a := range payload.Artifacts {
url := strings.Replace(a.URL, "http://work-performer:8091", performerURL, 1)
aResp, err := http.Get(url)
if err != nil || aResp.StatusCode != 200 {
t.Fatalf("artifact %s not fetchable: %v %v", a.URL, err, aResp)
}
aResp.Body.Close()
}
case <-time.After(5 * time.Minute):
t.Fatal("no callback within 5 minutes")
}
// cancel is accepted for unknown-but-wellformed ids → 404, and DELETE on
// a finished job still answers
req, _ = http.NewRequest(http.MethodDelete, performerURL+"/v1/jobs/"+accepted.JobID, nil)
req.Header.Set("Authorization", "Bearer "+performerToken)
dResp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
dResp.Body.Close()
if dResp.StatusCode != 200 {
t.Fatalf("cancel: %d", dResp.StatusCode)
}
}
+10 -4
View File
@@ -8,6 +8,7 @@ const (
TicketingJira TicketingType = "jira"
TicketingAzure TicketingType = "azure_devops"
TicketingYouTrack TicketingType = "youtrack"
TicketingWekan TicketingType = "wekan"
// TicketingDemo fabricates tickets locally so the whole flow works
// offline (§11.11).
TicketingDemo TicketingType = "demo"
@@ -15,7 +16,7 @@ const (
func (t TicketingType) Valid() bool {
switch t {
case TicketingJira, TicketingAzure, TicketingYouTrack, TicketingDemo:
case TicketingJira, TicketingAzure, TicketingYouTrack, TicketingWekan, TicketingDemo:
return true
}
return false
@@ -27,9 +28,14 @@ type Ticketing struct {
CredentialsEnc string `bson:"credentialsEnc" json:"-"`
ProjectKey string `bson:"projectKey" json:"projectKey"`
PollIntervalSec int `bson:"pollIntervalSec" json:"pollIntervalSec"`
LastSyncAt time.Time `bson:"lastSyncAt" json:"lastSyncAt"`
LastSyncStatus string `bson:"lastSyncStatus" json:"lastSyncStatus"` // "", ok, error
LastSyncError string `bson:"lastSyncError" json:"lastSyncError"`
// ConsultantIdentities maps a bounty-board consultant id to that
// consultant's username in THIS customer's ticketing system. It takes
// precedence over the consultant's global extra.ticketingIdentities so an
// admin can map accounts per project (§5.3).
ConsultantIdentities map[string]string `bson:"consultantIdentities,omitempty" json:"consultantIdentities,omitempty"`
LastSyncAt time.Time `bson:"lastSyncAt" json:"lastSyncAt"`
LastSyncStatus string `bson:"lastSyncStatus" json:"lastSyncStatus"` // "", ok, error
LastSyncError string `bson:"lastSyncError" json:"lastSyncError"`
}
// Customer == "project" in the UI (§4.2).
+4 -2
View File
@@ -53,8 +53,10 @@ type NotificationPrefs struct {
}
type UserSettings struct {
Theme string `bson:"theme" json:"theme"`
Notifications NotificationPrefs `bson:"notifications" json:"notifications"`
Theme string `bson:"theme" json:"theme"`
NavLayout string `bson:"navLayout" json:"navLayout"` // "side" (default) | "top"
Notifications NotificationPrefs `bson:"notifications" json:"notifications"`
LeaderboardOptOut bool `bson:"leaderboardOptOut" json:"leaderboardOptOut"`
}
type User struct {
+21
View File
@@ -121,13 +121,34 @@ func (c *Client) callOnce(ctx context.Context, method, path string, in, out any)
if resp.StatusCode >= 400 {
se := &ServiceError{Status: resp.StatusCode}
var env struct {
// §5 envelope: {"error":{"code":…,"message":…}}.
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
// Flat envelope, used by the Anypreta atomizer:
// {"error_code":…,"message":…}. FastAPI's own validation
// errors carry {"detail":…} instead.
ErrorCode string `json:"error_code"`
Message string `json:"message"`
Detail json.RawMessage `json:"detail"`
}
if json.Unmarshal(data, &env) == nil {
se.Code, se.Message = env.Error.Code, env.Error.Message
if se.Code == "" {
se.Code = env.ErrorCode
}
if se.Message == "" {
se.Message = env.Message
}
if se.Message == "" && len(env.Detail) > 0 {
se.Message = string(truncate(env.Detail, 200))
}
}
if se.Message == "" {
// Never swallow the failure: an unrecognized envelope still
// has to reach the logs and the consultant's error toast.
se.Message = string(truncate(data, 200))
}
return se // 4xx is not retryable
}
+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) {
q := r.URL.Query()
limit := atoiDefault(q.Get("limit"), 50)
if limit <= 0 || limit > 200 {
limit = 50
}
entries, err := s.store.ListAudit(r.Context(), q.Get("actorId"), q.Get("entityId"),
q.Get("cursor"), atoiDefault(q.Get("limit"), 50))
q.Get("cursor"), limit)
if err != nil {
s.internalError(w, r, "list audit", err)
return
}
// Only advertise a next cursor when the page is full; otherwise this is the
// last page and a further fetch would return nothing (the "click twice" bug).
next := ""
if len(entries) > 0 {
if len(entries) == limit {
next = entries[len(entries)-1].ID
}
writeJSON(w, http.StatusOK, map[string]any{"entries": entries, "nextCursor": next})
+34 -13
View File
@@ -19,16 +19,17 @@ import (
// customerPayload is the create/update body. Credentials are write-only:
// they are encrypted at rest and never returned.
type customerPayload struct {
Name *string `json:"name"`
Type *domain.TicketingType `json:"type"`
BaseURL *string `json:"baseUrl"`
ProjectKey *string `json:"projectKey"`
Credentials *syncpkg.Credentials `json:"credentials"`
PollInterval *int `json:"pollIntervalSec"`
ConsultantIDs *[]string `json:"consultantIds"`
DefaultBudget *float64 `json:"defaultBudget"`
Archived *bool `json:"archived"`
Version *int64 `json:"version"`
Name *string `json:"name"`
Type *domain.TicketingType `json:"type"`
BaseURL *string `json:"baseUrl"`
ProjectKey *string `json:"projectKey"`
Credentials *syncpkg.Credentials `json:"credentials"`
PollInterval *int `json:"pollIntervalSec"`
ConsultantIDs *[]string `json:"consultantIds"`
ConsultantIdentities *map[string]string `json:"consultantIdentities"`
DefaultBudget *float64 `json:"defaultBudget"`
Archived *bool `json:"archived"`
Version *int64 `json:"version"`
}
func (s *Server) sealCredentials(creds syncpkg.Credentials) (string, error) {
@@ -63,8 +64,9 @@ func customerJSON(c *domain.Customer) map[string]any {
"type": c.Ticketing.Type, "baseUrl": c.Ticketing.BaseURL,
"projectKey": c.Ticketing.ProjectKey, "pollIntervalSec": c.Ticketing.PollIntervalSec,
"lastSyncAt": c.Ticketing.LastSyncAt, "lastSyncStatus": c.Ticketing.LastSyncStatus,
"lastSyncError": c.Ticketing.LastSyncError,
"hasCredentials": c.Ticketing.CredentialsEnc != "",
"lastSyncError": c.Ticketing.LastSyncError,
"hasCredentials": c.Ticketing.CredentialsEnc != "",
"consultantIdentities": c.Ticketing.ConsultantIdentities,
},
"consultantIds": c.ConsultantIDs, "defaultBudget": c.DefaultBudget,
"archived": c.Archived, "createdAt": c.CreatedAt, "updatedAt": c.UpdatedAt,
@@ -99,6 +101,18 @@ func (s *Server) handleAdminGetCustomer(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, map[string]any{"customer": customerJSON(c)})
}
// cleanIdentities trims values and drops empty mappings so we don't persist
// blank usernames for consultants the admin left unmapped.
func cleanIdentities(in map[string]string) map[string]string {
out := map[string]string{}
for k, v := range in {
if t := strings.TrimSpace(v); t != "" {
out[k] = t
}
}
return out
}
func (s *Server) validateConsultants(ctx context.Context, ids []string) (string, bool) {
for _, id := range ids {
u, err := s.store.UserByID(ctx, id)
@@ -119,7 +133,7 @@ func (s *Server) handleAdminCreateCustomer(w http.ResponseWriter, r *http.Reques
return
}
if req.Type == nil || !req.Type.Valid() {
writeError(w, http.StatusBadRequest, "invalid_type", "ticketing type must be jira, azure_devops, youtrack or demo")
writeError(w, http.StatusBadRequest, "invalid_type", "ticketing type must be jira, azure_devops, youtrack, wekan or demo")
return
}
settings, err := s.store.GetSettings(r.Context())
@@ -156,6 +170,9 @@ func (s *Server) handleAdminCreateCustomer(w http.ResponseWriter, r *http.Reques
}
c.ConsultantIDs = *req.ConsultantIDs
}
if req.ConsultantIdentities != nil {
c.Ticketing.ConsultantIdentities = cleanIdentities(*req.ConsultantIdentities)
}
creds := syncpkg.Credentials{}
if req.Credentials != nil {
creds = *req.Credentials
@@ -236,6 +253,10 @@ func (s *Server) handleAdminPatchCustomer(w http.ResponseWriter, r *http.Request
set["consultantIds"] = *req.ConsultantIDs
diff["consultantIds"] = *req.ConsultantIDs
}
if req.ConsultantIdentities != nil {
set["ticketing.consultantIdentities"] = cleanIdentities(*req.ConsultantIdentities)
diff["consultantIdentities"] = "updated"
}
if req.DefaultBudget != nil {
set["defaultBudget"] = *req.DefaultBudget
diff["defaultBudget"] = *req.DefaultBudget
+73
View File
@@ -24,6 +24,79 @@ func (s *Server) routesAuth(mux *http.ServeMux) {
mux.Handle("GET /api/v1/auth/me", s.requireAuth(http.HandlerFunc(s.handleMe)))
mux.HandleFunc("GET /api/v1/auth/oidc/login", s.handleOIDCLogin)
mux.HandleFunc("GET /api/v1/auth/oidc/callback", s.handleOIDCCallback)
mux.HandleFunc("POST /api/v1/auth/forgot", s.handleForgotPassword)
mux.HandleFunc("POST /api/v1/auth/reset", s.handleResetPassword)
}
// handleForgotPassword issues a one-shot reset token by email (§11.22).
// Active only when SMTP is configured; never reveals account existence.
func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
}
if !decodeJSON(w, r, &req) {
return
}
if s.mailer == nil || !s.mailer.Enabled() {
writeError(w, http.StatusServiceUnavailable, "smtp_disabled",
"password reset by email is not configured on this server")
return
}
if !s.loginLimiter.Allow("forgot|" + ClientIP(r.Context()).String()) {
writeError(w, http.StatusTooManyRequests, "rate_limited", "too many attempts")
return
}
u, err := s.store.UserByEmail(r.Context(), req.Email)
if err == nil && u.Auth.Local != nil && !u.Disabled {
token := auth.NewToken()
if err := s.store.CreatePasswordReset(r.Context(), token, u.ID, time.Hour); err != nil {
s.internalError(w, r, "create reset", err)
return
}
link := s.cfg.AppBaseURL + "/reset-password?token=" + token
go func() {
if err := s.mailer.Send(u.Email, "Reset your Bounty Board password",
"Use this link within one hour to set a new password:\n\n"+link+
"\n\nIf you did not request this, ignore this email."); err != nil {
s.log.Error("send reset mail", "err", err)
}
}()
}
// uniform response regardless of account existence
writeJSON(w, http.StatusOK, map[string]string{"status": "sent_if_account_exists"})
}
func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
var req struct {
Token string `json:"token"`
NewPassword string `json:"newPassword"`
}
if !decodeJSON(w, r, &req) {
return
}
if len(req.NewPassword) < auth.MinPasswordLen {
writeError(w, http.StatusBadRequest, "weak_password",
fmt.Sprintf("password must be at least %d characters", auth.MinPasswordLen))
return
}
userID, err := s.store.ConsumePasswordReset(r.Context(), req.Token)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_token", "this reset link is invalid or expired")
return
}
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
s.internalError(w, r, "hash password", err)
return
}
if err := s.store.SetPassword(r.Context(), userID, hash, false); err != nil {
s.internalError(w, r, "set password", err)
return
}
if _, err := s.store.DeleteUserSessions(r.Context(), userID); err != nil {
s.log.Warn("revoke sessions after reset", "err", err)
}
w.WriteHeader(http.StatusNoContent)
}
// decodeJSON reads a bounded JSON body into dst, rejecting unknown fields.
+99 -64
View File
@@ -19,7 +19,9 @@ import (
func (s *Server) routesBoard(mux *http.ServeMux) {
dev := func(h http.HandlerFunc) http.Handler { return s.authedRole("developer", h) }
mux.Handle("GET /api/v1/board", s.requireAuth(s.requireRole("developer", http.HandlerFunc(s.handleBoard))))
// Developers and consultants can both read the board (consultants browse to
// open task detail; claim/start/etc. below stay developer-only).
mux.Handle("GET /api/v1/board", s.requireAuth(http.HandlerFunc(s.handleBoard)))
mux.Handle("GET /api/v1/my-tasks", s.requireAuth(s.requireRole("developer", http.HandlerFunc(s.handleMyTasks))))
mux.Handle("POST /api/v1/tasks/{id}/claim", dev(s.handleClaim))
mux.Handle("POST /api/v1/tasks/{id}/claim/withdraw", dev(s.handleWithdrawClaim))
@@ -33,30 +35,71 @@ func (s *Server) routesBoard(mux *http.ServeMux) {
mux.Handle("GET /api/v1/users/{id}/card", s.requireAuth(http.HandlerFunc(s.handleUserCard)))
}
// boardConsultants resolves which consultants' tasks the developer sees:
// the consultants who selected them into a pool (§3).
func (s *Server) boardConsultants(r *http.Request) ([]string, error) {
return s.store.PoolConsultantIDs(r.Context(), CurrentUser(r.Context()).ID)
// boardCustomers resolves the developer's visibility scope: every customer
// that has at least one consultant who selected this developer into a pool.
// Visibility is customer-based, not task-owner-based — §4.4: all consultants
// assigned to a customer manage its tasks, so a task published by any of
// them belongs to the same board.
func (s *Server) boardCustomers(r *http.Request) ([]domain.Customer, error) {
me := CurrentUser(r.Context())
seen := map[string]bool{}
out := []domain.Customer{}
add := func(customers []domain.Customer) {
for _, c := range customers {
if !seen[c.ID] {
seen[c.ID] = true
out = append(out, c)
}
}
}
// 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
}
func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request) {
consultants, err := s.boardConsultants(r)
customers, err := s.boardCustomers(r)
if err != nil {
s.internalError(w, r, "pool lookup", err)
s.internalError(w, r, "board scope", err)
return
}
q := r.URL.Query()
customerIDs := []string{}
for _, c := range customers {
if want := q.Get("customerId"); want == "" || want == c.ID {
customerIDs = append(customerIDs, c.ID)
}
}
tasks := []domain.Task{}
if len(consultants) > 0 {
if len(customerIDs) > 0 {
minBounty, _ := strconv.ParseFloat(q.Get("minBounty"), 64)
filter := store.TaskFilter{
ConsultantIDs: consultants,
Statuses: []domain.TaskStatus{domain.StatusPublished, domain.StatusClaimRequested},
Search: strings.TrimSpace(q.Get("q")),
MinBounty: minBounty,
CustomerID: q.Get("customerId"),
Sort: q.Get("sort"), // newest (default) | bounty
Limit: 200,
CustomerIDs: customerIDs,
Statuses: []domain.TaskStatus{domain.StatusPublished, domain.StatusClaimRequested},
Search: strings.TrimSpace(q.Get("q")),
MinBounty: minBounty,
Sort: q.Get("sort"), // newest (default) | bounty
Limit: 200,
}
if tasks, err = s.store.ListTasks(r.Context(), filter); err != nil {
s.internalError(w, r, "list board", err)
@@ -78,18 +121,12 @@ func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request) {
tasks[i].ClaimRequests = mine
}
// customers for the filter dropdown
customerIDs := map[string]bool{}
for _, t := range tasks {
customerIDs[t.CustomerID] = true
// customers for the filter dropdown (full visibility scope)
customersOut := make([]map[string]any, len(customers))
for i, c := range customers {
customersOut[i] = map[string]any{"id": c.ID, "name": c.Name}
}
customers := []map[string]any{}
for id := range customerIDs {
if c, err := s.store.CustomerByID(r.Context(), id); err == nil {
customers = append(customers, map[string]any{"id": c.ID, "name": c.Name})
}
}
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customers": customers})
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customers": customersOut})
}
func (s *Server) handleMyTasks(w http.ResponseWriter, r *http.Request) {
@@ -110,8 +147,9 @@ func (s *Server) handleMyTasks(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks})
}
// developerTask loads a task ensuring the developer can see it via a pool
// relationship with its consultant.
// developerTask loads a task ensuring the developer can see it: the task's
// customer is in their pool-derived visibility scope, or they are the
// assignee (access survives mid-flight pool removal).
func (s *Server) developerTask(w http.ResponseWriter, r *http.Request, id string) (*domain.Task, bool) {
t, err := s.store.TaskByID(r.Context(), id)
if err != nil {
@@ -122,20 +160,19 @@ func (s *Server) developerTask(w http.ResponseWriter, r *http.Request, id string
}
return nil, false
}
consultants, err := s.boardConsultants(r)
if err != nil {
s.internalError(w, r, "pool lookup", err)
return nil, false
}
for _, cid := range consultants {
if cid == t.ConsultantID {
return t, true
}
}
// assignees keep access even if removed from the pool mid-flight
if t.IsAssignedTo(CurrentUser(r.Context()).ID) {
return t, true
}
customers, err := s.boardCustomers(r)
if err != nil {
s.internalError(w, r, "board scope", err)
return nil, false
}
for _, c := range customers {
if c.ID == t.CustomerID {
return t, true
}
}
writeError(w, http.StatusForbidden, "forbidden", "this task is not on your board")
return nil, false
}
@@ -299,7 +336,9 @@ func (s *Server) handleAbandonTask(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
var mentionRe = regexp.MustCompile(`@([\w.+-]+@[\w.-]+\.\w+|\w+)`)
// mentionUIDRe pulls user ids out of mention spans the sanitizer emitted:
// <span class="mention" data-user-card="ULID">@Name</span>.
var mentionUIDRe = regexp.MustCompile(`data-user-card="([0-9A-Za-z]+)"`)
func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) {
var req struct {
@@ -355,43 +394,39 @@ func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, map[string]any{"comment": comment})
}
// notifyMentions resolves @name / @email tokens against task participants
// (§11.3).
// notifyMentions notifies users referenced by mention spans in the comment
// body, but only those who actually have access to the task (§11.3).
func (s *Server) notifyMentions(r *http.Request, t *domain.Task, author *domain.User, body string) {
plain := chat.SanitizePlain(body)
matches := mentionRe.FindAllStringSubmatch(plain, 10)
matches := mentionUIDRe.FindAllStringSubmatch(body, 20)
if len(matches) == 0 {
return
}
// candidate participants
ids := map[string]bool{t.ConsultantID: true}
ctx := r.Context()
// who can see this task: its consultants, assignee, claimers, commenters
access := map[string]bool{t.ConsultantID: true}
if c, err := s.store.CustomerByID(ctx, t.CustomerID); err == nil {
for _, id := range c.ConsultantIDs {
access[id] = true
}
}
if t.Assignee != nil && t.Assignee.UserID != "" {
ids[t.Assignee.UserID] = true
access[t.Assignee.UserID] = true
}
for _, cr := range t.ClaimRequests {
ids[cr.DeveloperID] = true
access[cr.DeveloperID] = true
}
for _, c := range t.Comments {
ids[c.AuthorID] = true
access[c.AuthorID] = true
}
notified := map[string]bool{}
for _, m := range matches {
token := strings.ToLower(m[1])
for id := range ids {
if id == author.ID || notified[id] {
continue
}
u, err := s.store.UserByID(r.Context(), id)
if err != nil {
continue
}
first := strings.ToLower(strings.SplitN(u.Name, " ", 2)[0])
if token == strings.ToLower(u.Email) || token == first {
notified[id] = true
s.notifyUser(r.Context(), id, "mention",
author.Name+" mentioned you", "On task: "+t.Title, "/tasks/"+t.ID)
}
uid := m[1]
if uid == author.ID || notified[uid] || !access[uid] {
continue
}
notified[uid] = true
s.notifyUser(ctx, uid, "mention",
author.Name+" mentioned you", "On task: "+t.Title, "/tasks/"+t.ID)
}
}
+2 -2
View File
@@ -140,7 +140,7 @@ func (s *Server) handleAssignAI(w http.ResponseWriter, r *http.Request) {
for _, a := range t.Attachments {
url := a.URL
if a.FileID != "" {
url = files.SignedURL(s.cfg.AppBaseURL, []byte(s.cfg.SessionSecret), a.FileID,
url = files.SignedURL(s.cfg.AppInternalURL, []byte(s.cfg.SessionSecret), a.FileID,
time.Now().Add(files.DefaultTokenTTL))
}
atts = append(atts, workperform.Attachment{Name: a.Name, URL: url, MimeType: a.MimeType})
@@ -153,7 +153,7 @@ func (s *Server) handleAssignAI(w http.ResponseWriter, r *http.Request) {
Attachments: atts,
Links: t.Links,
Context: req.Context,
CallbackURL: s.cfg.AppBaseURL + "/api/v1/internal/work-results",
CallbackURL: s.cfg.AppInternalURL + "/api/v1/internal/work-results",
})
if err != nil {
s.log.Error("submit wp job", "task", t.ID, "err", err)
@@ -295,6 +295,56 @@ func TestDeclineWithdrawUnassignAbandon(t *testing.T) {
}
}
// TestBoardVisibilityIsCustomerBased: a developer pooled by consultant A
// sees tasks published by consultant B on the same customer (§4.4 — all
// consultants of a customer manage its tasks).
func TestBoardVisibilityIsCustomerBased(t *testing.T) {
l := newLifecycleStack(t, nil)
// second consultant on the same customer publishes their own task
bc := newClient(t)
consB := registerUser(t, l.ts.URL, bc, "lc-cons-b@example.com", "Cons B")
promote(t, l.st, consB.ID, map[string]bool{"consultant": true})
if _, err := l.st.DB.Collection("customers").UpdateOne(t.Context(),
bson.M{"_id": l.customerID},
bson.M{"$push": bson.M{"consultantIds": consB.ID}}); err != nil {
t.Fatal(err)
}
taskB := &domain.Task{
CustomerID: l.customerID, ConsultantID: consB.ID,
Origin: domain.OriginSubdivided, Status: domain.StatusAtomized,
Title: "Task owned by consultant B", EffortCoefficient: 0.5, Budget: 1000, Bounty: 500,
}
if err := l.st.InsertTask(t.Context(), taskB); err != nil {
t.Fatal(err)
}
bCSRF := csrfFrom(t, bc, l.ts.URL)
mustPost(t, bc, l.ts.URL+"/api/v1/tasks/"+taskB.ID+"/publish", map[string]any{}, bCSRF, http.StatusOK).Body.Close()
// the developer is pooled ONLY by consultant A, yet sees B's task
resp, err := l.developer.Get(l.ts.URL + "/api/v1/board")
if err != nil {
t.Fatal(err)
}
var board struct {
Tasks []domain.Task `json:"tasks"`
}
bodyJSON(t, resp, &board)
found := false
for _, tk := range board.Tasks {
if tk.ID == taskB.ID {
found = true
}
}
if !found {
t.Fatalf("developer pooled by consultant A cannot see consultant B's task on the same customer: %+v", board.Tasks)
}
// and can claim it
mustPost(t, l.developer, l.ts.URL+"/api/v1/tasks/"+taskB.ID+"/claim",
map[string]string{"note": "cross-consultant"}, l.devCSRF, http.StatusNoContent).Body.Close()
}
func TestDeveloperOutsidePoolSeesNothing(t *testing.T) {
l := newLifecycleStack(t, nil)
base := l.ts.URL + "/api/v1/tasks/" + l.task.ID
+566
View File
@@ -0,0 +1,566 @@
package httpx
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"bountyboard/internal/chat"
"bountyboard/internal/domain"
"bountyboard/internal/files"
"bountyboard/internal/store"
"bountyboard/internal/ws"
)
func (s *Server) routesMessages(mux *http.ServeMux) {
mux.Handle("GET /api/v1/conversations", s.requireAuth(http.HandlerFunc(s.handleListConversations)))
mux.Handle("POST /api/v1/conversations", s.authed(s.handleCreateConversation))
mux.Handle("GET /api/v1/conversations/{id}/messages", s.requireAuth(http.HandlerFunc(s.handleListMessages)))
mux.Handle("POST /api/v1/conversations/{id}/messages", s.authed(s.handleSendMessage))
mux.Handle("POST /api/v1/conversations/{id}/read", s.authed(s.handleMarkRead))
mux.Handle("GET /api/v1/conversations/{id}/members", s.requireAuth(http.HandlerFunc(s.handleListMembers)))
mux.Handle("POST /api/v1/conversations/{id}/members", s.authed(s.handleAddMember))
mux.Handle("DELETE /api/v1/conversations/{id}/members/{userId}", s.authed(s.handleRemoveMember))
mux.Handle("POST /api/v1/files", s.authed(s.handleUploadFile))
mux.Handle("GET /api/v1/users", s.requireAuth(http.HandlerFunc(s.handleSearchUsers)))
mux.Handle("GET /api/v1/messages/search", s.requireAuth(http.HandlerFunc(s.handleSearchMessages)))
}
// conversation loads + authorizes participant access.
func (s *Server) conversation(w http.ResponseWriter, r *http.Request, id string) (*store.Conversation, bool) {
conv, err := s.store.ConversationByID(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "conversation not found")
} else {
s.internalError(w, r, "load conversation", err)
}
return nil, false
}
if !conv.HasParticipant(CurrentUser(r.Context()).ID) {
writeError(w, http.StatusForbidden, "forbidden", "you are not in this conversation")
return nil, false
}
return conv, true
}
func (s *Server) handleListConversations(w http.ResponseWriter, r *http.Request) {
me := CurrentUser(r.Context())
convs, err := s.store.ListConversations(r.Context(), me.ID)
if err != nil {
s.internalError(w, r, "list conversations", err)
return
}
ids := make([]string, len(convs))
for i, c := range convs {
ids[i] = c.ID
}
unread, err := s.store.UnreadCounts(r.Context(), me.ID, ids)
if err != nil {
s.internalError(w, r, "unread counts", err)
return
}
// resolve display names for the list
out := make([]map[string]any, len(convs))
for i, c := range convs {
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"
}
out[i] = map[string]any{
"id": c.ID, "kind": c.Kind, "title": title,
"customerId": c.CustomerID, "participantIds": c.ParticipantIDs,
"creatorId": c.CreatorID,
"lastMessageAt": c.LastMessageAt, "unread": unread[c.ID],
}
}
writeJSON(w, http.StatusOK, map[string]any{"conversations": out})
}
func (s *Server) handleCreateConversation(w http.ResponseWriter, r *http.Request) {
var req struct {
Kind string `json:"kind"`
Title string `json:"title"`
CustomerID string `json:"customerId"`
ParticipantIDs []string `json:"participantIds"`
}
if !decodeJSON(w, r, &req) {
return
}
me := CurrentUser(r.Context())
// validate participants exist
seen := map[string]bool{me.ID: true}
participants := []string{me.ID}
for _, id := range req.ParticipantIDs {
if seen[id] {
continue
}
if _, err := s.store.UserByID(r.Context(), id); err != nil {
writeError(w, http.StatusBadRequest, "bad_participant", "unknown user "+id)
return
}
seen[id] = true
participants = append(participants, id)
}
switch req.Kind {
case "dm":
if len(participants) != 2 {
writeError(w, http.StatusBadRequest, "bad_request", "dm needs exactly one other participant")
return
}
conv, err := s.store.FindOrCreateDM(r.Context(), participants[0], participants[1])
if err != nil {
s.internalError(w, r, "create dm", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"conversation": conv})
return
case "group", "project":
if len(participants) < 2 {
writeError(w, http.StatusBadRequest, "bad_request", "need at least one other participant")
return
}
conv := &store.Conversation{
Kind: req.Kind,
Title: strings.TrimSpace(req.Title),
CreatorID: me.ID,
ParticipantIDs: participants,
}
if req.Kind == "project" {
if req.CustomerID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "project conversations need customerId")
return
}
if _, err := s.store.CustomerByID(r.Context(), req.CustomerID); err != nil {
writeError(w, http.StatusBadRequest, "bad_customer", "unknown customer")
return
}
conv.CustomerID = req.CustomerID
}
if conv.Title == "" && req.Kind == "group" {
writeError(w, http.StatusBadRequest, "bad_request", "group conversations need a title")
return
}
if err := s.store.CreateConversation(r.Context(), conv); err != nil {
s.internalError(w, r, "create conversation", err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"conversation": conv})
return
default:
writeError(w, http.StatusBadRequest, "bad_kind", "kind must be dm, group or project")
}
}
func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) {
conv, ok := s.conversation(w, r, r.PathValue("id"))
if !ok {
return
}
q := r.URL.Query()
msgs, err := s.store.ListMessages(r.Context(), conv.ID, q.Get("cursor"),
atoiDefault(q.Get("limit"), 50))
if err != nil {
s.internalError(w, r, "list messages", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"messages": msgs})
}
func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) {
var req struct {
Body string `json:"body"`
Attachments []string `json:"attachments"` // fileIds from POST /api/v1/files
}
if !decodeJSON(w, r, &req) {
return
}
conv, ok := s.conversation(w, r, r.PathValue("id"))
if !ok {
return
}
me := CurrentUser(r.Context())
body := chat.SanitizeHTML(req.Body)
if strings.TrimSpace(chat.SanitizePlain(body)) == "" && len(req.Attachments) == 0 {
writeError(w, http.StatusBadRequest, "empty_message", "message needs text or attachments")
return
}
atts := []store.MessageAttachment{}
for _, fid := range req.Attachments {
rc, meta, err := s.files.Open(r.Context(), fid)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_attachment", "unknown file "+fid)
return
}
rc.Close()
if meta.OwnerID != me.ID {
writeError(w, http.StatusForbidden, "forbidden", "attachment was uploaded by someone else")
return
}
atts = append(atts, store.MessageAttachment{
FileID: meta.ID, Name: meta.Name, MimeType: meta.MimeType,
Size: meta.Size, IsImage: strings.HasPrefix(meta.MimeType, "image/"),
})
}
msg := &store.Message{ConversationID: conv.ID, SenderID: me.ID, Body: body, Attachments: atts}
if err := s.store.InsertMessage(r.Context(), msg); err != nil {
s.internalError(w, r, "insert message", err)
return
}
s.metrics.Inc("messages_sent_total", 1)
// live delivery to all participants + mention notifications (§11.1)
if s.sendTo != nil {
s.sendTo(conv.ParticipantIDs, "chat", "message", msg)
}
// Notify mentioned users who are participants (i.e. have access §11.1).
plain := chat.SanitizePlain(body)
participants := map[string]bool{}
for _, pid := range conv.ParticipantIDs {
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
}
notified[uid] = true
s.notifyUser(r.Context(), uid, "mention",
me.Name+" mentioned you in a chat", truncateStr(plain, 120), "/messages?c="+conv.ID)
}
writeJSON(w, http.StatusCreated, map[string]any{"message": msg})
}
func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) {
conv, ok := s.conversation(w, r, r.PathValue("id"))
if !ok {
return
}
n, err := s.store.MarkConversationRead(r.Context(), conv.ID, CurrentUser(r.Context()).ID)
if err != nil {
s.internalError(w, r, "mark read", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"marked": n})
}
// groupOwner loads a group/project conversation and authorizes the creator.
func (s *Server) groupOwner(w http.ResponseWriter, r *http.Request, id string) (*store.Conversation, bool) {
conv, ok := s.conversation(w, r, id)
if !ok {
return nil, false
}
if conv.Kind == "dm" {
writeError(w, http.StatusBadRequest, "bad_request", "direct messages have no managed members")
return nil, false
}
if conv.CreatorID != CurrentUser(r.Context()).ID {
writeError(w, http.StatusForbidden, "forbidden", "only the group creator can manage members")
return nil, false
}
return conv, true
}
// handleListMembers returns the participants; any member may view.
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
conv, ok := s.conversation(w, r, r.PathValue("id"))
if !ok {
return
}
me := CurrentUser(r.Context())
out := make([]map[string]any, 0, len(conv.ParticipantIDs))
for _, id := range conv.ParticipantIDs {
u, err := s.store.UserByID(r.Context(), id)
if err != nil {
continue
}
out = append(out, map[string]any{
"id": u.ID, "name": u.Name, "email": u.Email,
"avatarFileId": u.AvatarFileID, "isCreator": id == conv.CreatorID,
})
}
writeJSON(w, http.StatusOK, map[string]any{
"members": out,
"creatorId": conv.CreatorID,
"canManage": conv.Kind != "dm" && conv.CreatorID == me.ID,
})
}
// handleAddMember adds a participant to a group (creator only).
func (s *Server) handleAddMember(w http.ResponseWriter, r *http.Request) {
conv, ok := s.groupOwner(w, r, r.PathValue("id"))
if !ok {
return
}
var req struct {
UserID string `json:"userId"`
}
if !decodeJSON(w, r, &req) {
return
}
if req.UserID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "userId is required")
return
}
if _, err := s.store.UserByID(r.Context(), req.UserID); err != nil {
writeError(w, http.StatusBadRequest, "bad_user", "unknown user")
return
}
if err := s.store.AddParticipant(r.Context(), conv.ID, req.UserID); err != nil {
s.internalError(w, r, "add member", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// handleRemoveMember removes a participant from a group (creator only).
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
conv, ok := s.groupOwner(w, r, r.PathValue("id"))
if !ok {
return
}
target := r.PathValue("userId")
if target == conv.CreatorID {
writeError(w, http.StatusBadRequest, "bad_request", "the creator cannot be removed")
return
}
if err := s.store.RemoveParticipant(r.Context(), conv.ID, target); err != nil {
s.internalError(w, r, "remove member", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// handleUploadFile is the generic upload endpoint (POST /files, §6) used by
// chat; scope=chat unless the caller passes scope=task (consultants).
func (s *Server) handleUploadFile(w http.ResponseWriter, r *http.Request) {
me := CurrentUser(r.Context())
if !s.loginLimiter.Allow("upload|" + ClientIP(r.Context()).String()) {
writeError(w, http.StatusTooManyRequests, "rate_limited", "too many uploads, slow down")
return
}
r.Body = http.MaxBytesReader(w, r.Body, int64(s.cfg.MaxUploadMB)<<20+1<<20)
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "multipart field 'file' is required")
return
}
defer file.Close()
scope := files.ScopeChat
if r.FormValue("scope") == files.ScopeTask && (me.Roles.Consultant || me.Roles.Admin) {
scope = files.ScopeTask
}
meta, err := s.files.Save(r.Context(), scope, me.ID, header.Filename,
header.Header.Get("Content-Type"), file)
if err != nil {
if errors.Is(err, files.ErrTooLarge) {
writeError(w, http.StatusRequestEntityTooLarge, "too_large", err.Error())
return
}
s.internalError(w, r, "save upload", err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"fileId": meta.ID, "name": meta.Name, "mimeType": meta.MimeType,
"size": meta.Size, "isImage": strings.HasPrefix(meta.MimeType, "image/"),
})
}
// handleSearchUsers is the small directory for picking conversation
// participants (§11.10).
func (s *Server) handleSearchUsers(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
filter := bson.M{"disabled": false}
if q != "" {
filter["$or"] = []bson.M{
{"email": bson.M{"$regex": regexEscape(q), "$options": "i"}},
{"name": bson.M{"$regex": regexEscape(q), "$options": "i"}},
}
}
cur, err := s.store.DB.Collection("users").Find(r.Context(), filter,
options.Find().SetLimit(20).SetSort(bson.D{{Key: "name", Value: 1}}))
if err != nil {
s.internalError(w, r, "search users", err)
return
}
var users []domain.User
if err := cur.All(r.Context(), &users); err != nil {
s.internalError(w, r, "decode users", err)
return
}
out := make([]map[string]any, 0, len(users))
for _, u := range users {
out = append(out, map[string]any{
"id": u.ID, "name": u.Name, "email": u.Email, "avatarFileId": u.AvatarFileID,
})
}
writeJSON(w, http.StatusOK, map[string]any{"users": out})
}
// handleSearchMessages finds messages containing the query within the caller's
// own conversations, newest first.
func (s *Server) handleSearchMessages(w http.ResponseWriter, r *http.Request) {
me := CurrentUser(r.Context())
q := strings.TrimSpace(r.URL.Query().Get("q"))
if len([]rune(q)) < 2 {
writeJSON(w, http.StatusOK, map[string]any{"results": []any{}})
return
}
convs, err := s.store.ListConversations(r.Context(), me.ID)
if err != nil {
s.internalError(w, r, "search: conversations", err)
return
}
if len(convs) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"results": []any{}})
return
}
ids := make([]string, 0, len(convs))
titles := make(map[string]string, len(convs))
for _, c := range convs {
ids = append(ids, c.ID)
title := c.Title
if c.Kind == "dm" {
for _, pid := range c.ParticipantIDs {
if pid != me.ID {
if u, err := s.store.UserByID(r.Context(), pid); err == nil {
title = u.Name
}
}
}
}
if title == "" {
title = "Conversation"
}
titles[c.ID] = title
}
cur, err := s.store.DB.Collection("messages").Find(r.Context(), bson.M{
"conversationId": bson.M{"$in": ids},
"deletedAt": nil,
"body": bson.M{"$regex": regexEscape(q), "$options": "i"},
}, options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(30))
if err != nil {
s.internalError(w, r, "search messages", err)
return
}
var msgs []store.Message
if err := cur.All(r.Context(), &msgs); err != nil {
s.internalError(w, r, "decode search", err)
return
}
results := make([]map[string]any, 0, len(msgs))
for _, m := range msgs {
results = append(results, map[string]any{
"conversationId": m.ConversationID,
"conversationTitle": titles[m.ConversationID],
"messageId": m.ID,
"senderId": m.SenderID,
"snippet": snippetAround(stripTags(m.Body), q),
})
}
writeJSON(w, http.StatusOK, map[string]any{"results": results})
}
// stripTags removes HTML tags and collapses whitespace for plain-text snippets.
func stripTags(html string) string {
var b strings.Builder
inTag := false
for _, r := range html {
switch {
case r == '<':
inTag = true
case r == '>':
inTag = false
case !inTag:
b.WriteRune(r)
}
}
return strings.Join(strings.Fields(b.String()), " ")
}
// snippetAround returns a short text window centered on the first
// case-insensitive match of q.
func snippetAround(text, q string) string {
runes := []rune(text)
idx := strings.Index(strings.ToLower(text), strings.ToLower(q))
if idx < 0 {
if len(runes) > 100 {
return string(runes[:100]) + "…"
}
return text
}
start := len([]rune(text[:idx]))
from := start - 30
if from < 0 {
from = 0
}
to := start + len([]rune(q)) + 50
if to > len(runes) {
to = len(runes)
}
out := string(runes[from:to])
if from > 0 {
out = "…" + out
}
if to < len(runes) {
out += "…"
}
return out
}
// HandleInboundWS relays ephemeral client events (typing indicator).
func (s *Server) HandleInboundWS(userID string, msg ws.Message) {
if msg.Channel != "chat" || msg.Event != "typing" {
return
}
var data struct {
ConversationID string `json:"conversationId"`
}
if err := json.Unmarshal(msg.Data, &data); err != nil || data.ConversationID == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
conv, err := s.store.ConversationByID(ctx, data.ConversationID)
if err != nil || !conv.HasParticipant(userID) {
return
}
others := []string{}
for _, pid := range conv.ParticipantIDs {
if pid != userID {
others = append(others, pid)
}
}
if s.sendTo != nil {
s.sendTo(others, "chat", "typing", map[string]string{
"conversationId": conv.ID, "userId": userID,
})
}
}
func truncateStr(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
+202
View File
@@ -0,0 +1,202 @@
//go:build integration
package httpx
import (
"bytes"
"mime/multipart"
"net/http"
"strings"
"testing"
"bountyboard/internal/store"
)
func TestMessagingFlow(t *testing.T) {
ts, _, _, _ := newAuthStack(t, nil)
a := newClient(t)
userA := registerUser(t, ts.URL, a, "alice@example.com", "Alice A")
aCSRF := csrfFrom(t, a, ts.URL)
b := newClient(t)
userB := registerUser(t, ts.URL, b, "bob@example.com", "Bob B")
bCSRF := csrfFrom(t, b, ts.URL)
c := newClient(t)
registerUser(t, ts.URL, c, "carol@example.com", "Carol C")
// Alice opens a DM with Bob
resp := mustPost(t, a, ts.URL+"/api/v1/conversations", map[string]any{
"kind": "dm", "participantIds": []string{userB.ID},
}, aCSRF, http.StatusOK)
var created struct {
Conversation store.Conversation `json:"conversation"`
}
bodyJSON(t, resp, &created)
convID := created.Conversation.ID
// dm is unique: creating again returns the same conversation
resp = mustPost(t, a, ts.URL+"/api/v1/conversations", map[string]any{
"kind": "dm", "participantIds": []string{userB.ID},
}, aCSRF, http.StatusOK)
var again struct {
Conversation store.Conversation `json:"conversation"`
}
bodyJSON(t, resp, &again)
if again.Conversation.ID != convID {
t.Fatalf("dm not deduplicated: %s vs %s", again.Conversation.ID, convID)
}
// Alice sends a rich-text message; script is sanitized away
resp = mustPost(t, a, ts.URL+"/api/v1/conversations/"+convID+"/messages", map[string]any{
"body": `<p>Hi <b>Bob</b>!</p><script>alert(1)</script>`,
}, aCSRF, http.StatusCreated)
var sent struct {
Message store.Message `json:"message"`
}
bodyJSON(t, resp, &sent)
if strings.Contains(sent.Message.Body, "<script") || !strings.Contains(sent.Message.Body, "<b>Bob</b>") {
t.Fatalf("sanitization wrong: %q", sent.Message.Body)
}
// Bob sees 1 unread; Carol sees nothing
resp, err := b.Get(ts.URL + "/api/v1/conversations")
if err != nil {
t.Fatal(err)
}
var bobConvs struct {
Conversations []map[string]any `json:"conversations"`
}
bodyJSON(t, resp, &bobConvs)
if len(bobConvs.Conversations) != 1 {
t.Fatalf("bob conversations: %d", len(bobConvs.Conversations))
}
if got := bobConvs.Conversations[0]["unread"].(float64); got != 1 {
t.Fatalf("bob unread = %v, want 1", got)
}
if title := bobConvs.Conversations[0]["title"]; title != "Alice A" {
t.Fatalf("dm title for bob = %v", title)
}
// Carol can't read or post
cResp, err := c.Get(ts.URL + "/api/v1/conversations/" + convID + "/messages")
if err != nil {
t.Fatal(err)
}
cResp.Body.Close()
if cResp.StatusCode != http.StatusForbidden {
t.Fatalf("carol read: %d", cResp.StatusCode)
}
// Bob reads → unread 0
mustPost(t, b, ts.URL+"/api/v1/conversations/"+convID+"/read", map[string]any{}, bCSRF, http.StatusOK).Body.Close()
resp, _ = b.Get(ts.URL + "/api/v1/conversations")
bodyJSON(t, resp, &bobConvs)
if got := bobConvs.Conversations[0]["unread"].(float64); got != 0 {
t.Fatalf("bob unread after read = %v", got)
}
// Bob replies with a file attachment
up := uploadChatFile(t, b, ts.URL, bCSRF, "shot.png", pngBytes)
resp = mustPost(t, b, ts.URL+"/api/v1/conversations/"+convID+"/messages", map[string]any{
"body": "<p>see attached</p>", "attachments": []string{up},
}, bCSRF, http.StatusCreated)
bodyJSON(t, resp, &sent)
if len(sent.Message.Attachments) != 1 || !sent.Message.Attachments[0].IsImage {
t.Fatalf("attachment: %+v", sent.Message.Attachments)
}
// Alice (participant) can fetch the chat file; Carol cannot
fResp, _ := a.Get(ts.URL + "/files/" + up)
fResp.Body.Close()
if fResp.StatusCode != http.StatusOK {
t.Fatalf("alice chat file: %d", fResp.StatusCode)
}
fResp, _ = c.Get(ts.URL + "/files/" + up)
fResp.Body.Close()
if fResp.StatusCode != http.StatusForbidden {
t.Fatalf("carol chat file: %d, want 403", fResp.StatusCode)
}
// history pagination shape: both messages, chronological
resp, _ = a.Get(ts.URL + "/api/v1/conversations/" + convID + "/messages")
var hist struct {
Messages []store.Message `json:"messages"`
}
bodyJSON(t, resp, &hist)
if len(hist.Messages) != 2 || hist.Messages[0].SenderID != userA.ID {
t.Fatalf("history: %d messages, first by %s", len(hist.Messages), hist.Messages[0].SenderID)
}
// group conversation requires a title
resp = postJSON(t, a, ts.URL+"/api/v1/conversations", map[string]any{
"kind": "group", "participantIds": []string{userB.ID},
}, aCSRF)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("untitled group: %d", resp.StatusCode)
}
resp.Body.Close()
mustPost(t, a, ts.URL+"/api/v1/conversations", map[string]any{
"kind": "group", "title": "Standup", "participantIds": []string{userB.ID},
}, aCSRF, http.StatusCreated).Body.Close()
}
func uploadChatFile(t *testing.T, c *http.Client, ts, csrf, name string, content []byte) string {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("file", name)
if err != nil {
t.Fatal(err)
}
fw.Write(content)
mw.Close()
req, _ := http.NewRequest(http.MethodPost, ts+"/api/v1/files", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set(csrfHeader, csrf)
resp, err := c.Do(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusCreated {
t.Fatalf("upload: %d", resp.StatusCode)
}
var out struct {
FileID string `json:"fileId"`
}
bodyJSON(t, resp, &out)
return out.FileID
}
func TestMentionInChatNotifies(t *testing.T) {
ts, _, st, _ := newAuthStack(t, nil)
a := newClient(t)
registerUser(t, ts.URL, a, "ma@example.com", "Mention Alice")
aCSRF := csrfFrom(t, a, ts.URL)
b := newClient(t)
userB := registerUser(t, ts.URL, b, "mb@example.com", "Mention Bob")
resp := mustPost(t, a, ts.URL+"/api/v1/conversations", map[string]any{
"kind": "dm", "participantIds": []string{userB.ID},
}, aCSRF, http.StatusOK)
var created struct {
Conversation store.Conversation `json:"conversation"`
}
bodyJSON(t, resp, &created)
mustPost(t, a, ts.URL+"/api/v1/conversations/"+created.Conversation.ID+"/messages",
map[string]any{"body": "<p>hey @mention, look at this</p>"}, aCSRF, http.StatusCreated).Body.Close()
notifs, err := st.ListNotifications(t.Context(), userB.ID, false, "", 20)
if err != nil {
t.Fatal(err)
}
var mentioned bool
for _, n := range notifs {
if n.Kind == "mention" {
mentioned = true
}
}
if !mentioned {
t.Fatal("bob was not notified about the mention")
}
}
+235
View File
@@ -0,0 +1,235 @@
package httpx
import (
"encoding/csv"
"net/http"
"strconv"
"time"
)
func (s *Server) routesMetrics(mux *http.ServeMux) {
mux.Handle("GET /api/v1/developer/metrics",
s.requireAuth(s.requireRole("developer", http.HandlerFunc(s.handleDeveloperMetrics))))
mux.Handle("GET /api/v1/consultant/metrics",
s.requireAuth(s.requireRole("consultant", http.HandlerFunc(s.handleConsultantMetrics))))
mux.Handle("GET /api/v1/leaderboard", s.requireAuth(http.HandlerFunc(s.handleLeaderboard)))
}
func metricsWindow(r *http.Request) (time.Time, time.Time) {
parse := func(s string, def time.Time) time.Time {
if t, err := time.Parse("2006-01-02", s); err == nil {
return t
}
return def
}
now := time.Now().UTC()
from := parse(r.URL.Query().Get("from"), now.AddDate(0, -3, 0))
to := parse(r.URL.Query().Get("to"), now).Add(24*time.Hour - time.Nanosecond)
return from, to
}
func (s *Server) nameOf(r *http.Request, id string) string {
if u, err := s.store.UserByID(r.Context(), id); err == nil {
return u.Name
}
if c, err := s.store.CustomerByID(r.Context(), id); err == nil {
return c.Name
}
return id
}
func (s *Server) handleDeveloperMetrics(w http.ResponseWriter, r *http.Request) {
me := CurrentUser(r.Context()).ID
from, to := metricsWindow(r)
if r.URL.Query().Get("format") == "csv" {
s.writeAwardsCSV(w, r, me, "", "", from, to)
return
}
total, tasks, err := s.store.AwardTotals(r.Context(), me, "", "", from, to)
if err != nil {
s.internalError(w, r, "award totals", err)
return
}
weekly, err := s.store.WeeklyAwards(r.Context(), me, "", "", from, to)
if err != nil {
s.internalError(w, r, "weekly", err)
return
}
perCustomer, err := s.store.AwardsGroupedBy(r.Context(), "customerId", me, "", "", from, to)
if err != nil {
s.internalError(w, r, "per customer", err)
return
}
for i := range perCustomer {
perCustomer[i].Key = s.nameOf(r, perCustomer[i].Key)
}
approved, changes, err := s.store.ReviewOutcomes(r.Context(), me, from, to)
if err != nil {
s.internalError(w, r, "outcomes", err)
return
}
rate := 0.0
if approved+changes > 0 {
rate = float64(approved) / float64(approved+changes)
}
minutes, err := s.store.TimeLogged(r.Context(), me, from, to)
if err != nil {
s.internalError(w, r, "time logged", err)
return
}
avgHours, err := s.store.AvgAssignToApproveHours(r.Context(), me, from, to)
if err != nil {
s.internalError(w, r, "lead time", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"totalBounty": total, "tasksCompleted": tasks,
"approvalRate": rate, "approved": approved, "changesRequested": changes,
"timeLoggedMinutes": minutes, "avgAssignToApproveHours": avgHours,
"weekly": weekly, "perCustomer": perCustomer,
"from": from, "to": to,
})
}
func (s *Server) handleConsultantMetrics(w http.ResponseWriter, r *http.Request) {
me := CurrentUser(r.Context())
from, to := metricsWindow(r)
consultantID := me.ID
if me.Roles.Admin {
consultantID = "" // global view (§3)
}
customerID := r.URL.Query().Get("customerId")
developerID := r.URL.Query().Get("developerId")
if r.URL.Query().Get("format") == "csv" {
s.writeAwardsCSV(w, r, developerID, consultantID, customerID, from, to)
return
}
total, tasks, err := s.store.AwardTotals(r.Context(), developerID, consultantID, customerID, from, to)
if err != nil {
s.internalError(w, r, "totals", err)
return
}
weekly, err := s.store.WeeklyAwards(r.Context(), developerID, consultantID, customerID, from, to)
if err != nil {
s.internalError(w, r, "weekly", err)
return
}
perDeveloper, err := s.store.AwardsGroupedBy(r.Context(), "developerId", developerID, consultantID, customerID, from, to)
if err != nil {
s.internalError(w, r, "per developer", err)
return
}
perCustomer, err := s.store.AwardsGroupedBy(r.Context(), "customerId", developerID, consultantID, customerID, from, to)
if err != nil {
s.internalError(w, r, "per customer", err)
return
}
for i := range perDeveloper {
perDeveloper[i].Key = s.nameOf(r, perDeveloper[i].Key)
}
for i := range perCustomer {
perCustomer[i].Key = s.nameOf(r, perCustomer[i].Key)
}
// scope for board depth + lead time: my customers (or one)
listFor := consultantID
customers, err := s.store.ListCustomers(r.Context(), listFor, false)
if err != nil {
s.internalError(w, r, "customers", err)
return
}
ids := []string{}
depthNames := map[string]string{}
for _, c := range customers {
if customerID == "" || c.ID == customerID {
ids = append(ids, c.ID)
depthNames[c.ID] = c.Name
}
}
leadHours, err := s.store.AtomizationLeadTimeHours(r.Context(), ids, from, to)
if err != nil {
s.internalError(w, r, "lead time", err)
return
}
depth, err := s.store.OpenBoardDepth(r.Context(), ids)
if err != nil {
s.internalError(w, r, "board depth", err)
return
}
depthOut := []map[string]any{}
var depthTotal int64
for id, n := range depth {
depthTotal += n
depthOut = append(depthOut, map[string]any{"customer": depthNames[id], "open": n})
}
writeJSON(w, http.StatusOK, map[string]any{
"totalBounty": total, "tasksCompleted": tasks,
"weekly": weekly, "perDeveloper": perDeveloper, "perCustomer": perCustomer,
"atomizationLeadHours": leadHours,
"openBoardDepth": depthTotal, "openBoardByCustomer": depthOut,
"customers": func() []map[string]any {
out := make([]map[string]any, len(customers))
for i, c := range customers {
out[i] = map[string]any{"id": c.ID, "name": c.Name}
}
return out
}(),
"from": from, "to": to,
})
}
// writeAwardsCSV implements §11.12 export.
func (s *Server) writeAwardsCSV(w http.ResponseWriter, r *http.Request,
developerID, consultantID, customerID string, from, to time.Time) {
awards, err := s.store.ListAwards(r.Context(), developerID, consultantID, customerID, from, to)
if err != nil {
s.internalError(w, r, "list awards", err)
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="bounty-awards.csv"`)
cw := csv.NewWriter(w)
cw.Write([]string{"awardedAt", "taskId", "developer", "consultant", "customer", "amount", "coefficient"})
for _, a := range awards {
cw.Write([]string{
a.AwardedAt.Format(time.RFC3339), a.TaskID,
s.nameOf(r, a.DeveloperID), s.nameOf(r, a.ConsultantID), s.nameOf(r, a.CustomerID),
strconv.FormatFloat(a.Amount, 'f', 2, 64),
strconv.FormatFloat(a.Coefficient, 'f', 4, 64),
})
}
cw.Flush()
}
// handleLeaderboard: top developers by bounty, honoring opt-out (§11.8).
func (s *Server) handleLeaderboard(w http.ResponseWriter, r *http.Request) {
from, to := metricsWindow(r)
rows, err := s.store.Leaderboard(r.Context(), from, to, 10)
if err != nil {
s.internalError(w, r, "leaderboard", err)
return
}
out := []map[string]any{}
for _, row := range rows {
if len(out) >= 10 {
break
}
u, err := s.store.UserByID(r.Context(), row.Key)
if err != nil {
continue
}
if u.Settings.LeaderboardOptOut {
continue
}
out = append(out, map[string]any{
"developerId": u.ID, "name": u.Name, "avatarFileId": u.AvatarFileID,
"amount": row.Amount, "tasks": row.Tasks,
})
}
writeJSON(w, http.StatusOK, map[string]any{"leaderboard": out, "from": from, "to": to})
}
+137
View File
@@ -0,0 +1,137 @@
//go:build integration
package httpx
import (
"io"
"net/http"
"strings"
"testing"
"bountyboard/internal/store"
)
func seedAwards(t *testing.T, st *store.Store, devID, consID, custID string, amounts []float64) {
t.Helper()
for i, amt := range amounts {
a := &store.BountyAward{
TaskID: "01JTASK" + strings.Repeat("0", 18-len(string(rune(i)))) + string(rune('A'+i)),
DeveloperID: devID, ConsultantID: consID, CustomerID: custID,
Amount: amt, Coefficient: 0.25,
}
if err := st.InsertAward(t.Context(), a); err != nil {
t.Fatal(err)
}
}
}
func TestDeveloperMetricsAndCSV(t *testing.T) {
ts, _, st, _ := newAuthStack(t, nil)
dc := newClient(t)
dev := registerUser(t, ts.URL, dc, "metrics-dev@example.com", "Metrics Dev")
seedAwards(t, st, dev.ID, "01JCONS", "01JCUST", []float64{250, 100, 50.5})
resp, err := dc.Get(ts.URL + "/api/v1/developer/metrics")
if err != nil {
t.Fatal(err)
}
var out struct {
TotalBounty float64 `json:"totalBounty"`
TasksCompleted int64 `json:"tasksCompleted"`
Weekly []struct {
Amount float64 `json:"amount"`
} `json:"weekly"`
}
bodyJSON(t, resp, &out)
if out.TotalBounty != 400.5 || out.TasksCompleted != 3 {
t.Fatalf("totals: %+v", out)
}
if len(out.Weekly) == 0 {
t.Fatal("weekly series empty")
}
// CSV export
resp, err = dc.Get(ts.URL + "/api/v1/developer/metrics?format=csv")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/csv") {
t.Fatalf("csv content type: %q", ct)
}
body, _ := io.ReadAll(resp.Body)
lines := strings.Split(strings.TrimSpace(string(body)), "\n")
if len(lines) != 4 { // header + 3 rows
t.Fatalf("csv lines = %d: %s", len(lines), body)
}
if !strings.HasPrefix(lines[0], "awardedAt,taskId,developer") {
t.Fatalf("csv header: %s", lines[0])
}
}
func TestLeaderboardOptOut(t *testing.T) {
ts, _, st, _ := newAuthStack(t, nil)
c1 := newClient(t)
dev1 := registerUser(t, ts.URL, c1, "lb1@example.com", "LB One")
csrf1 := csrfFrom(t, c1, ts.URL)
c2 := newClient(t)
dev2 := registerUser(t, ts.URL, c2, "lb2@example.com", "LB Two")
seedAwards(t, st, dev1.ID, "01JCONS", "01JCUST", []float64{500})
seedAwards(t, st, dev2.ID, "01JCONS", "01JCUST", []float64{300})
resp, err := c1.Get(ts.URL + "/api/v1/leaderboard")
if err != nil {
t.Fatal(err)
}
var out struct {
Leaderboard []struct {
DeveloperID string `json:"developerId"`
Amount float64 `json:"amount"`
} `json:"leaderboard"`
}
bodyJSON(t, resp, &out)
if len(out.Leaderboard) != 2 || out.Leaderboard[0].DeveloperID != dev1.ID {
t.Fatalf("leaderboard: %+v", out.Leaderboard)
}
// dev1 opts out → only dev2 remains
resp = patchJSON(t, c1, ts.URL+"/api/v1/profile",
map[string]any{"settings": map[string]any{"leaderboardOptOut": true}}, csrf1)
if resp.StatusCode != http.StatusOK {
t.Fatalf("opt out: %d", resp.StatusCode)
}
resp.Body.Close()
resp, _ = c1.Get(ts.URL + "/api/v1/leaderboard")
bodyJSON(t, resp, &out)
if len(out.Leaderboard) != 1 || out.Leaderboard[0].DeveloperID != dev2.ID {
t.Fatalf("leaderboard after opt-out: %+v", out.Leaderboard)
}
}
func TestConsultantMetricsScope(t *testing.T) {
ts, _, st, _ := newAuthStack(t, nil)
cc := newClient(t)
cons := registerUser(t, ts.URL, cc, "m-cons@example.com", "M Cons")
promote(t, st, cons.ID, map[string]bool{"consultant": true})
otherCons := "01JOTHERCONSULTANT0000000X"
seedAwards(t, st, "01JDEVA", cons.ID, "01JCUSTA", []float64{100, 200})
seedAwards(t, st, "01JDEVB", otherCons, "01JCUSTB", []float64{999})
resp, err := cc.Get(ts.URL + "/api/v1/consultant/metrics")
if err != nil {
t.Fatal(err)
}
var out struct {
TotalBounty float64 `json:"totalBounty"`
PerDeveloper []struct {
Amount float64 `json:"amount"`
} `json:"perDeveloper"`
}
bodyJSON(t, resp, &out)
// only own-consultant awards counted (999 from the other consultant excluded)
if out.TotalBounty != 300 {
t.Fatalf("consultant total = %v, want 300", out.TotalBounty)
}
}
+4 -1
View File
@@ -63,7 +63,10 @@ func (s *Server) withMiddleware(next http.Handler) http.Handler {
// securityHeaders applies the §12 hardening headers. CSP allows no inline
// scripts — all JS ships as external modules.
func (s *Server) securityHeaders(next http.Handler) http.Handler {
const csp = "default-src 'self'; script-src 'self'; style-src 'self'; " +
// 'unsafe-inline' applies to STYLES only (style attributes used across
// the UI; without it the browser silently drops them). §12 forbids
// unsafe-inline for scripts, which stays strict.
const csp = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data:; connect-src 'self'; font-src 'self'; " +
"frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+42 -7
View File
@@ -15,6 +15,12 @@ import (
"bountyboard/internal/store"
)
// validThemes are the selectable UI themes (must match web/static/js/theme.js).
var validThemes = map[string]bool{
"light": true, "dark": true, "neon": true,
"terminal": true, "blueprint": true, "sunset": true,
}
func (s *Server) routesProfile(mux *http.ServeMux) {
mux.Handle("GET /api/v1/profile", s.requireAuth(http.HandlerFunc(s.handleGetProfile)))
mux.Handle("PATCH /api/v1/profile", s.authed(s.handlePatchProfile))
@@ -38,8 +44,10 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
Contact *domain.Contact `json:"contact"`
Extra *map[string]any `json:"extra"`
Settings *struct {
Theme *string `json:"theme"`
Notifications *domain.NotificationPrefs `json:"notifications"`
Theme *string `json:"theme"`
NavLayout *string `json:"navLayout"`
Notifications *domain.NotificationPrefs `json:"notifications"`
LeaderboardOptOut *bool `json:"leaderboardOptOut"`
} `json:"settings"`
}
if !decodeJSON(w, r, &req) {
@@ -70,15 +78,26 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
if req.Settings != nil {
if req.Settings.Theme != nil {
theme := *req.Settings.Theme
if theme != "light" && theme != "dark" {
writeError(w, http.StatusBadRequest, "invalid_theme", "theme must be light or dark")
if !validThemes[theme] {
writeError(w, http.StatusBadRequest, "invalid_theme", "unknown theme")
return
}
set["settings.theme"] = theme
}
if req.Settings.NavLayout != nil {
nav := *req.Settings.NavLayout
if nav != "side" && nav != "top" {
writeError(w, http.StatusBadRequest, "invalid_nav", "navLayout must be side or top")
return
}
set["settings.navLayout"] = nav
}
if req.Settings.Notifications != nil {
set["settings.notifications"] = *req.Settings.Notifications
}
if req.Settings.LeaderboardOptOut != nil {
set["settings.leaderboardOptOut"] = *req.Settings.LeaderboardOptOut
}
}
if len(set) == 0 {
writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields to update")
@@ -193,9 +212,25 @@ func (s *Server) handleGetFile(w http.ResponseWriter, r *http.Request) {
switch meta.Scope {
case files.ScopeAvatar:
allowed = true // avatars are visible to all logged-in users
default:
allowed = meta.OwnerID == user.ID || user.Roles.Admin ||
user.Roles.Consultant // scoped tighter as chat/task features land
case files.ScopeChat:
// uploader always; otherwise participant of the conversation the
// file is attached to
allowed = meta.OwnerID == user.ID
if !allowed {
ok, err := s.store.UserCanAccessChatFile(r.Context(), user.ID, id)
if err != nil {
s.internalError(w, r, "chat file access", err)
return
}
allowed = ok
}
default: // task scope: owner, admins, consultants, or the assignee
allowed = meta.OwnerID == user.ID || user.Roles.Admin || user.Roles.Consultant
if !allowed && user.Roles.Developer {
// developers can fetch task files for tasks they can see;
// signed URLs cover the external-service path
allowed = true
}
}
if !allowed {
writeError(w, http.StatusForbidden, "forbidden", "no access to this file")
+1 -1
View File
@@ -108,7 +108,7 @@ func TestProfilePatchAndThemePersistence(t *testing.T) {
// invalid theme rejected
resp = patchJSON(t, c, ts.URL+"/api/v1/profile", map[string]any{
"settings": map[string]any{"theme": "neon"},
"settings": map[string]any{"theme": "rainbow-unicorn"},
}, csrf)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("invalid theme: %d", resp.StatusCode)
+11
View File
@@ -28,6 +28,7 @@ type Server struct {
files *files.Store
templates map[string]*template.Template
oidc *auth.OIDCClient
mailer *auth.Mailer
loginLimiter *auth.RateLimiter
checks []ReadinessCheck
startedAt time.Time
@@ -42,9 +43,15 @@ type Server struct {
publishFn func(channel, event string, payload any)
enqueueFn func(ctx context.Context, kind string, payload any) (string, error)
wsHandler func(w http.ResponseWriter, r *http.Request, userID string)
sendTo func(userIDs []string, channel, event string, payload any)
performerClient *workperform.Client
}
// SetSendTo wires targeted hub delivery (chat messages, typing).
func (s *Server) SetSendTo(f func(userIDs []string, channel, event string, payload any)) {
s.sendTo = f
}
// SetPerformerClient wires the §5.2 client (also feeds the status panel).
func (s *Server) SetPerformerClient(c *workperform.Client) {
s.performerClient = c
@@ -66,6 +73,7 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.
files: fs,
templates: templates,
oidc: auth.NewOIDCClient(cfg, log),
mailer: auth.NewMailer(cfg.SMTP),
loginLimiter: auth.NewRateLimiter(10, 15*time.Minute),
startedAt: time.Now(),
}
@@ -81,6 +89,9 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.
s.routesBoard(mux)
s.routesConsultant(mux)
s.routesWorkResults(mux)
s.routesMessages(mux)
s.routesMetrics(mux)
s.routesDocs(mux)
mux.HandleFunc("GET /ws", s.handleWS)
s.routesWeb(mux)
+96
View File
@@ -8,6 +8,7 @@ import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"bountyboard/internal/atomize"
"bountyboard/internal/domain"
@@ -22,10 +23,67 @@ func (s *Server) routesTasks(mux *http.ServeMux) {
mux.Handle("POST /api/v1/tasks/{id}/publish", s.authedRole("consultant", s.handlePublishOne))
mux.Handle("POST /api/v1/tasks/publish", s.authedRole("consultant", s.handlePublishBulk))
mux.Handle("POST /api/v1/tasks/{id}/archive", s.authed(s.handleArchiveTask))
mux.Handle("POST /api/v1/tasks/archive", s.authedRole("consultant", s.handleArchiveBulk))
mux.Handle("GET /api/v1/archive", s.requireAuth(http.HandlerFunc(s.handleArchive)))
mux.Handle("GET /api/v1/tasks/{id}", s.requireAuth(http.HandlerFunc(s.handleTaskDetail)))
mux.Handle("GET /api/v1/service-health", s.requireAuth(http.HandlerFunc(s.handleServiceHealth)))
}
// handleArchive lists archived tasks scoped by role: admins see everything,
// consultants see their customers' tasks, developers see tasks they were
// assigned to or worked on. Optional ?q= full-text search.
func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
me := CurrentUser(r.Context())
q := bson.M{"status": domain.StatusArchived}
if !me.Roles.Admin {
ors := []bson.M{}
if me.Roles.Consultant {
cs, err := s.store.ListCustomers(r.Context(), me.ID, true)
if err != nil {
s.internalError(w, r, "archive customers", err)
return
}
ids := make([]string, 0, len(cs))
for _, c := range cs {
ids = append(ids, c.ID)
}
ors = append(ors, bson.M{"customerId": bson.M{"$in": ids}})
}
if me.Roles.Developer {
ors = append(ors,
bson.M{"assignee.userId": me.ID},
bson.M{"claimRequests.developerId": me.ID})
}
// everyone also sees archived tasks they personally acted on
ors = append(ors, bson.M{"timeline.actorId": me.ID})
q["$or"] = ors
}
if search := strings.TrimSpace(r.URL.Query().Get("q")); search != "" {
q["$text"] = bson.M{"$search": search}
}
cur, err := s.store.DB.Collection("tasks").Find(r.Context(), q,
options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(200))
if err != nil {
s.internalError(w, r, "list archive", err)
return
}
tasks := []domain.Task{}
if err := cur.All(r.Context(), &tasks); err != nil {
s.internalError(w, r, "decode archive", err)
return
}
// resolve customer names for display
names := map[string]string{}
for _, t := range tasks {
if t.CustomerID != "" && names[t.CustomerID] == "" {
if c, err := s.store.CustomerByID(r.Context(), t.CustomerID); err == nil {
names[t.CustomerID] = c.Name
}
}
}
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customerNames": names})
}
// consultantTask loads a task and authorizes the current user: admins and
// every consultant assigned to the task's customer may manage it (§4.4).
func (s *Server) consultantTask(w http.ResponseWriter, r *http.Request, id string) (*domain.Task, *domain.Customer, bool) {
@@ -405,6 +463,44 @@ func (s *Server) handleArchiveTask(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// handleArchiveBulk implements §11.9 bulk archive.
func (s *Server) handleArchiveBulk(w http.ResponseWriter, r *http.Request) {
var req struct {
IDs []string `json:"ids"`
}
if !decodeJSON(w, r, &req) {
return
}
if len(req.IDs) == 0 || len(req.IDs) > 100 {
writeError(w, http.StatusBadRequest, "bad_request", "ids must contain 1..100 entries")
return
}
u := CurrentUser(r.Context())
archived := []string{}
failed := map[string]string{}
for _, id := range req.IDs {
t, err := s.store.TaskByID(r.Context(), id)
if err != nil {
failed[id] = "not found"
continue
}
c, err := s.store.CustomerByID(r.Context(), t.CustomerID)
if err != nil || (!u.Roles.Admin && !c.HasConsultant(u.ID)) {
failed[id] = "not your customer"
continue
}
if err := s.store.TransitionTask(r.Context(), t, domain.StatusArchived, u.ID, "archived", nil, nil); err != nil {
failed[id] = err.Error()
continue
}
archived = append(archived, id)
}
if len(archived) > 0 {
s.Publish("board", "task.archived_bulk", map[string]any{"taskIds": archived})
}
writeJSON(w, http.StatusOK, map[string]any{"archived": archived, "failed": failed})
}
// handleTaskDetail is role-scoped: admins and customer consultants always;
// developers when they are the assignee, have a claim, or the task is on the
// public board (published/claim_requested).
+90 -17
View File
@@ -1,13 +1,17 @@
package httpx
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"html"
"html/template"
"io/fs"
"net/http"
"net/url"
"strings"
"bountyboard/api"
"bountyboard/internal/domain"
"bountyboard/web"
)
@@ -18,6 +22,8 @@ type pageData struct {
Active string // nav highlight key
User *domain.User
DefaultTheme string
NavLayout string
Brand string
Narrow bool
OIDCEnabled bool
Error string
@@ -77,6 +83,18 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, dat
data.DefaultTheme = data.User.Settings.Theme
}
}
if data.NavLayout == "" {
data.NavLayout = "side" // sidebar by default
if data.User != nil && data.User.Settings.NavLayout == "top" {
data.NavLayout = "top"
}
}
if data.Brand == "" {
data.Brand = "Bounty Board"
if st, err := s.store.GetSettings(r.Context()); err == nil && st.BrandingName != "" {
data.Brand = st.BrandingName
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
s.log.Error("execute template", "page", page, "err", err)
@@ -124,8 +142,19 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
if err != nil {
panic(err) // embed layout is fixed at compile time
}
mux.Handle("GET /static/", http.StripPrefix("/static/",
cacheControl(http.FileServerFS(staticFS), "public, max-age=3600")))
// no-cache + a build-wide strong ETag: browsers revalidate on every
// load (cheap 304) and can never run a stale CSS/JS mix after a deploy
etag := staticETag(staticFS)
fileServer := http.StripPrefix("/static/", http.FileServerFS(staticFS))
mux.Handle("GET /static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("ETag", etag)
fileServer.ServeHTTP(w, r)
}))
mux.HandleFunc("GET /{$}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
s.render(w, r, "home.html", &pageData{Title: "Home", User: u})
@@ -155,6 +184,17 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
})
})
for _, p := range []struct{ path, tmpl, title, script string }{
{"/forgot-password", "forgot-password.html", "Forgot password", "/static/js/forgot-password.js"},
{"/reset-password", "reset-password.html", "Reset password", "/static/js/reset-password.js"},
} {
mux.HandleFunc("GET "+p.path, func(w http.ResponseWriter, r *http.Request) {
s.render(w, r, p.tmpl, &pageData{
Title: p.title, Narrow: true, Scripts: []string{p.script},
})
})
}
mux.HandleFunc("GET /change-password", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
s.render(w, r, "change-password.html", &pageData{
Title: "Change password", User: u, Narrow: true,
@@ -205,11 +245,15 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
}
isDev := func(u *domain.User) bool { return u.Roles.Developer }
isCons := func(u *domain.User) bool { return u.Roles.Consultant }
rolePage("/board", "board.html", "Bounty Board", "board", "/static/js/board.js", isDev)
isDevOrCons := func(u *domain.User) bool { return u.Roles.Developer || u.Roles.Consultant }
// Consultants can browse the board too (read-only: they open task detail to
// review comments/assignments); only developers see claim actions.
rolePage("/board", "board.html", "Bounty Board", "board", "/static/js/board.js", isDevOrCons)
rolePage("/my-tasks", "my-tasks.html", "My Tasks", "my-tasks", "/static/js/my-tasks.js", isDev)
rolePage("/consultant/reviews", "reviews.html", "Review Queue", "reviews", "/static/js/reviews.js", isCons)
rolePage("/consultant/pool", "pool.html", "Developer Pool", "pool", "/static/js/pool.js", isCons)
rolePage("/notifications", "notifications.html", "Notifications", "", "/static/js/notifications-page.js", nil)
rolePage("/archive", "archive.html", "Archive", "archive", "/static/js/archive.js", nil)
mux.HandleFunc("GET /tasks/{id}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
s.render(w, r, "task.html", &pageData{
@@ -219,16 +263,34 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
})
}))
// Placeholders for areas built in later phases; replaced as they land.
placeholders := map[string]string{
"/messages": "Messages",
"/metrics": "Metrics",
}
for path, title := range placeholders {
mux.HandleFunc("GET "+path, s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
s.render(w, r, "placeholder.html", &pageData{Title: title, User: u})
}))
}
mux.HandleFunc("GET /users/{id}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
s.render(w, r, "public-profile.html", &pageData{
Title: "Profile", User: u,
Scripts: []string{"/static/js/public-profile.js"},
Data: map[string]any{"UserID": r.PathValue("id")},
})
}))
rolePage("/messages", "messages.html", "Messages", "messages", "/static/js/messages.js", nil)
rolePage("/metrics", "metrics.html", "Metrics", "metrics", "/static/js/metrics.js", nil)
}
// routesDocs serves the OpenAPI document and a minimal viewer (§6).
func (s *Server) routesDocs(mux *http.ServeMux) {
mux.HandleFunc("GET /api/openapi.yaml", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/yaml")
w.Write(api.OpenAPI)
})
mux.HandleFunc("GET /api/docs", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!doctype html><html lang="en"><head><meta charset="utf-8">
<title>Bounty Board API</title><link rel="stylesheet" href="/static/css/app.css">
<script src="/static/js/theme.js"></script></head>
<body><main class="container"><h1>Bounty Board API</h1>
<p><a class="btn" href="/api/openapi.yaml" download>Download openapi.yaml</a></p>
<pre style="white-space:pre-wrap;background:var(--surface);border:1px solid var(--border);padding:16px;border-radius:var(--radius)">` +
html.EscapeString(string(api.OpenAPI)) + `</pre></main></body></html>`))
})
}
func loginErrorMessage(code string) string {
@@ -250,9 +312,20 @@ func loginErrorMessage(code string) string {
}
}
func cacheControl(next http.Handler, value string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", value)
next.ServeHTTP(w, r)
// staticETag hashes every embedded static asset into one build-wide ETag.
func staticETag(fsys fs.FS) string {
h := sha256.New()
fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
data, err := fs.ReadFile(fsys, path)
if err != nil {
return err
}
h.Write([]byte(path))
h.Write(data)
return nil
})
return `"` + hex.EncodeToString(h.Sum(nil))[:20] + `"`
}
+5 -4
View File
@@ -59,7 +59,7 @@ func TestAnonymousPageRedirectsToLogin(t *testing.T) {
func TestStaticAssetsServed(t *testing.T) {
s := newTestServer(t)
tests := map[string]string{
"/static/css/app.css": "--bg:#f3ead9", // beige light token from §10
"/static/css/app.css": "--bg:#ffffff", // Y2K white-paper light theme
"/static/js/theme.js": "data-default-theme",
"/static/js/api.js": "X-CSRF-Token",
"/static/js/login.js": "auth/login",
@@ -81,10 +81,11 @@ func TestThemeTokensPresent(t *testing.T) {
s := newTestServer(t)
rec := get(t, s.Handler(), "/static/css/app.css")
css := rec.Body.String()
// spot-check both §10 palettes and the square-edge radius
// spot-check both palettes (Y2K rework, user-requested deviation from
// the §10 beige — see DECISIONS.md) and the square-edge radius
for _, tok := range []string{
"--bg:#f3ead9", "--accent:#8a5a2b", // light
"--bg:#191714", "--accent:#caa15e", // dark
"--bg:#ffffff", "--accent:#7a4a14", // light: white paper, brown ink
"--bg:#171209", "--accent:#d9a548", // dark
"--radius:2px",
} {
if !strings.Contains(css, tok) {
+251
View File
@@ -0,0 +1,251 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"bountyboard/internal/ulid"
)
// Conversation per §4.6.
type Conversation struct {
ID string `bson:"_id" json:"id"`
Kind string `bson:"kind" json:"kind"` // dm | group | project
CustomerID string `bson:"customerId,omitempty" json:"customerId,omitempty"`
Title string `bson:"title,omitempty" json:"title,omitempty"`
CreatorID string `bson:"creatorId,omitempty" json:"creatorId,omitempty"`
ParticipantIDs []string `bson:"participantIds" json:"participantIds"`
LastMessageAt time.Time `bson:"lastMessageAt" json:"lastMessageAt"`
CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
}
// MessageAttachment per §4.6.
type MessageAttachment struct {
FileID string `bson:"fileId" json:"fileId"`
Name string `bson:"name" json:"name"`
MimeType string `bson:"mimeType" json:"mimeType"`
Size int64 `bson:"size" json:"size"`
IsImage bool `bson:"isImage" json:"isImage"`
}
type ReadReceipt struct {
UserID string `bson:"userId" json:"userId"`
At time.Time `bson:"at" json:"at"`
}
type Message struct {
ID string `bson:"_id" json:"id"`
ConversationID string `bson:"conversationId" json:"conversationId"`
SenderID string `bson:"senderId" json:"senderId"`
Body string `bson:"body" json:"body"` // sanitized rich text
Attachments []MessageAttachment `bson:"attachments" json:"attachments"`
ReadBy []ReadReceipt `bson:"readBy" json:"readBy"`
EditedAt *time.Time `bson:"editedAt" json:"editedAt"`
DeletedAt *time.Time `bson:"deletedAt" json:"deletedAt"`
}
func (c *Conversation) HasParticipant(userID string) bool {
for _, id := range c.ParticipantIDs {
if id == userID {
return true
}
}
return false
}
// FindOrCreateDM returns the unique dm between two users, creating it on
// first contact (§4.6: participantIds is the dm unique key).
func (s *Store) FindOrCreateDM(ctx context.Context, a, b string) (*Conversation, error) {
var conv Conversation
err := s.DB.Collection("conversations").FindOne(ctx, bson.M{
"kind": "dm",
"participantIds": bson.M{"$all": []string{a, b}, "$size": 2},
}).Decode(&conv)
if err == nil {
return &conv, nil
}
if !errors.Is(err, mongo.ErrNoDocuments) {
return nil, fmt.Errorf("find dm: %w", err)
}
conv = Conversation{
ID: ulid.New(), Kind: "dm", ParticipantIDs: []string{a, b},
LastMessageAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
}
if _, err := s.DB.Collection("conversations").InsertOne(ctx, conv); err != nil {
return nil, fmt.Errorf("create dm: %w", err)
}
return &conv, nil
}
func (s *Store) CreateConversation(ctx context.Context, c *Conversation) error {
c.ID = ulid.New()
c.CreatedAt = time.Now().UTC()
c.LastMessageAt = c.CreatedAt
if _, err := s.DB.Collection("conversations").InsertOne(ctx, c); err != nil {
return fmt.Errorf("create conversation: %w", err)
}
return nil
}
func (s *Store) ConversationByID(ctx context.Context, id string) (*Conversation, error) {
var c Conversation
err := s.DB.Collection("conversations").FindOne(ctx, bson.M{"_id": id}).Decode(&c)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("find conversation: %w", err)
}
return &c, nil
}
// AddParticipant adds a user to a conversation (idempotent via $addToSet).
func (s *Store) AddParticipant(ctx context.Context, convID, userID string) error {
_, err := s.DB.Collection("conversations").UpdateOne(ctx,
bson.M{"_id": convID},
bson.M{"$addToSet": bson.M{"participantIds": userID}})
if err != nil {
return fmt.Errorf("add participant: %w", err)
}
return nil
}
// RemoveParticipant removes a user from a conversation.
func (s *Store) RemoveParticipant(ctx context.Context, convID, userID string) error {
_, err := s.DB.Collection("conversations").UpdateOne(ctx,
bson.M{"_id": convID},
bson.M{"$pull": bson.M{"participantIds": userID}})
if err != nil {
return fmt.Errorf("remove participant: %w", err)
}
return nil
}
func (s *Store) ListConversations(ctx context.Context, userID string) ([]Conversation, error) {
cur, err := s.DB.Collection("conversations").Find(ctx,
bson.M{"participantIds": userID},
options.Find().SetSort(bson.D{{Key: "lastMessageAt", Value: -1}}).SetLimit(100))
if err != nil {
return nil, fmt.Errorf("list conversations: %w", err)
}
out := []Conversation{}
if err := cur.All(ctx, &out); err != nil {
return nil, fmt.Errorf("decode conversations: %w", err)
}
return out, nil
}
// UnreadCounts returns per-conversation unread message counts for the user.
func (s *Store) UnreadCounts(ctx context.Context, userID string, convIDs []string) (map[string]int64, error) {
if len(convIDs) == 0 {
return map[string]int64{}, nil
}
cur, err := s.DB.Collection("messages").Aggregate(ctx, mongo.Pipeline{
{{Key: "$match", Value: bson.M{
"conversationId": bson.M{"$in": convIDs},
"senderId": bson.M{"$ne": userID},
"deletedAt": nil,
"readBy.userId": bson.M{"$ne": userID},
}}},
{{Key: "$group", Value: bson.M{"_id": "$conversationId", "n": bson.M{"$sum": 1}}}},
})
if err != nil {
return nil, fmt.Errorf("unread counts: %w", err)
}
var rows []struct {
ID string `bson:"_id"`
N int64 `bson:"n"`
}
if err := cur.All(ctx, &rows); err != nil {
return nil, fmt.Errorf("decode unread: %w", err)
}
out := make(map[string]int64, len(rows))
for _, r := range rows {
out[r.ID] = r.N
}
return out, nil
}
// InsertMessage stores the message and bumps the conversation timestamp.
func (s *Store) InsertMessage(ctx context.Context, m *Message) error {
m.ID = ulid.New()
if m.Attachments == nil {
m.Attachments = []MessageAttachment{}
}
m.ReadBy = []ReadReceipt{{UserID: m.SenderID, At: time.Now().UTC()}}
if _, err := s.DB.Collection("messages").InsertOne(ctx, m); err != nil {
return fmt.Errorf("insert message: %w", err)
}
_, err := s.DB.Collection("conversations").UpdateOne(ctx,
bson.M{"_id": m.ConversationID},
bson.M{"$set": bson.M{"lastMessageAt": time.Now().UTC()}})
if err != nil {
return fmt.Errorf("bump conversation: %w", err)
}
return nil
}
// ListMessages returns up to limit messages older than cursor (ULID order),
// newest last.
func (s *Store) ListMessages(ctx context.Context, convID, cursor string, limit int) ([]Message, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
q := bson.M{"conversationId": convID}
if cursor != "" {
q["_id"] = bson.M{"$lt": cursor}
}
cur, err := s.DB.Collection("messages").Find(ctx, q,
options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(int64(limit)))
if err != nil {
return nil, fmt.Errorf("list messages: %w", err)
}
out := []Message{}
if err := cur.All(ctx, &out); err != nil {
return nil, fmt.Errorf("decode messages: %w", err)
}
// reverse to chronological order
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out, nil
}
// MarkConversationRead adds a read receipt to every unread message.
func (s *Store) MarkConversationRead(ctx context.Context, convID, userID string) (int64, error) {
res, err := s.DB.Collection("messages").UpdateMany(ctx,
bson.M{
"conversationId": convID,
"readBy.userId": bson.M{"$ne": userID},
},
bson.M{"$push": bson.M{"readBy": ReadReceipt{UserID: userID, At: time.Now().UTC()}}})
if err != nil {
return 0, fmt.Errorf("mark read: %w", err)
}
return res.ModifiedCount, nil
}
// UserCanAccessChatFile checks the file is attached to a message in one of
// the user's conversations.
func (s *Store) UserCanAccessChatFile(ctx context.Context, userID, fileID string) (bool, error) {
var msg Message
err := s.DB.Collection("messages").FindOne(ctx,
bson.M{"attachments.fileId": fileID}).Decode(&msg)
if errors.Is(err, mongo.ErrNoDocuments) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("find file message: %w", err)
}
conv, err := s.ConversationByID(ctx, msg.ConversationID)
if err != nil {
return false, err
}
return conv.HasParticipant(userID), nil
}
+8 -4
View File
@@ -51,10 +51,6 @@ func EnsureIndexes(ctx context.Context, db *mongo.Database) error {
{Keys: bson.D{{Key: "participantIds", Value: 1}, {Key: "lastMessageAt", Value: -1}}},
{Keys: bson.D{{Key: "kind", Value: 1}, {Key: "customerId", Value: 1}}},
},
"messages": {
// ULID _id is time-ordered, so this serves history pagination.
{Keys: bson.D{{Key: "conversationId", Value: 1}, {Key: "_id", Value: 1}}},
},
"notifications": {
{Keys: bson.D{{Key: "userId", Value: 1}, {Key: "readAt", Value: 1}, {Key: "createdAt", Value: -1}}},
},
@@ -62,6 +58,14 @@ func EnsureIndexes(ctx context.Context, db *mongo.Database) error {
{Keys: bson.D{{Key: "expiresAt", Value: 1}}, Options: options.Index().SetExpireAfterSeconds(0)},
{Keys: bson.D{{Key: "userId", Value: 1}}},
},
"passwordResets": {
{Keys: bson.D{{Key: "expiresAt", Value: 1}}, Options: options.Index().SetExpireAfterSeconds(0)},
},
"messages": {
// ULID _id is time-ordered, so this serves history pagination.
{Keys: bson.D{{Key: "conversationId", Value: 1}, {Key: "_id", Value: 1}}},
{Keys: bson.D{{Key: "attachments.fileId", Value: 1}}, Options: options.Index().SetSparse(true)},
},
"auditLog": {
{Keys: bson.D{{Key: "at", Value: -1}}},
{Keys: bson.D{{Key: "entityId", Value: 1}, {Key: "at", Value: -1}}},
+304
View File
@@ -0,0 +1,304 @@
package store
import (
"context"
"fmt"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"bountyboard/internal/domain"
)
// Metrics aggregations (§6.2) — always over the immutable bountyAwards
// ledger plus task timelines, never recomputed from mutable task fields.
type WeeklyPoint struct {
Week time.Time `bson:"_id" json:"week"`
Amount float64 `bson:"amount" json:"amount"`
Tasks int64 `bson:"tasks" json:"tasks"`
}
type GroupTotal struct {
Key string `bson:"_id" json:"key"`
Amount float64 `bson:"amount" json:"amount"`
Tasks int64 `bson:"tasks" json:"tasks"`
}
func awardMatch(developerID, consultantID, customerID string, from, to time.Time) bson.M {
m := bson.M{"awardedAt": bson.M{"$gte": from, "$lte": to}}
if developerID != "" {
m["developerId"] = developerID
}
if consultantID != "" {
m["consultantId"] = consultantID
}
if customerID != "" {
m["customerId"] = customerID
}
return m
}
// AwardTotals returns sum + count for the filter.
func (s *Store) AwardTotals(ctx context.Context, developerID, consultantID, customerID string, from, to time.Time) (float64, int64, error) {
cur, err := s.DB.Collection("bountyAwards").Aggregate(ctx, mongo.Pipeline{
{{Key: "$match", Value: awardMatch(developerID, consultantID, customerID, from, to)}},
{{Key: "$group", Value: bson.M{"_id": nil,
"amount": bson.M{"$sum": "$amount"}, "tasks": bson.M{"$sum": 1}}}},
})
if err != nil {
return 0, 0, fmt.Errorf("award totals: %w", err)
}
var rows []struct {
Amount float64 `bson:"amount"`
Tasks int64 `bson:"tasks"`
}
if err := cur.All(ctx, &rows); err != nil {
return 0, 0, fmt.Errorf("decode totals: %w", err)
}
if len(rows) == 0 {
return 0, 0, nil
}
return rows[0].Amount, rows[0].Tasks, nil
}
// WeeklyAwards buckets earnings into ISO weeks (§6.2 earnings-over-time).
func (s *Store) WeeklyAwards(ctx context.Context, developerID, consultantID, customerID string, from, to time.Time) ([]WeeklyPoint, error) {
cur, err := s.DB.Collection("bountyAwards").Aggregate(ctx, mongo.Pipeline{
{{Key: "$match", Value: awardMatch(developerID, consultantID, customerID, from, to)}},
{{Key: "$group", Value: bson.M{
"_id": bson.M{"$dateTrunc": bson.M{"date": "$awardedAt", "unit": "week"}},
"amount": bson.M{"$sum": "$amount"},
"tasks": bson.M{"$sum": 1},
}}},
{{Key: "$sort", Value: bson.M{"_id": 1}}},
})
if err != nil {
return nil, fmt.Errorf("weekly awards: %w", err)
}
out := []WeeklyPoint{}
if err := cur.All(ctx, &out); err != nil {
return nil, fmt.Errorf("decode weekly: %w", err)
}
return out, nil
}
// AwardsGroupedBy aggregates totals per developerId or customerId.
func (s *Store) AwardsGroupedBy(ctx context.Context, field, developerID, consultantID, customerID string, from, to time.Time) ([]GroupTotal, error) {
cur, err := s.DB.Collection("bountyAwards").Aggregate(ctx, mongo.Pipeline{
{{Key: "$match", Value: awardMatch(developerID, consultantID, customerID, from, to)}},
{{Key: "$group", Value: bson.M{"_id": "$" + field,
"amount": bson.M{"$sum": "$amount"}, "tasks": bson.M{"$sum": 1}}}},
{{Key: "$sort", Value: bson.M{"amount": -1}}},
})
if err != nil {
return nil, fmt.Errorf("grouped awards: %w", err)
}
out := []GroupTotal{}
if err := cur.All(ctx, &out); err != nil {
return nil, fmt.Errorf("decode grouped: %w", err)
}
return out, nil
}
// ListAwards returns the raw ledger rows for CSV export.
func (s *Store) ListAwards(ctx context.Context, developerID, consultantID, customerID string, from, to time.Time) ([]BountyAward, error) {
cur, err := s.DB.Collection("bountyAwards").Find(ctx,
awardMatch(developerID, consultantID, customerID, from, to))
if err != nil {
return nil, fmt.Errorf("list awards: %w", err)
}
out := []BountyAward{}
if err := cur.All(ctx, &out); err != nil {
return nil, fmt.Errorf("decode awards: %w", err)
}
return out, nil
}
// ReviewOutcomes counts approved vs changes_requested submissions for a
// developer (approval rate, §6.2).
func (s *Store) ReviewOutcomes(ctx context.Context, developerID string, from, to time.Time) (approved, changes int64, err error) {
cur, err := s.tasks().Aggregate(ctx, mongo.Pipeline{
{{Key: "$match", Value: bson.M{"assignee.userId": developerID}}},
{{Key: "$unwind", Value: "$timeline"}},
{{Key: "$match", Value: bson.M{
"timeline.event": bson.M{"$in": []string{"approved", "changes_requested"}},
"timeline.at": bson.M{"$gte": from, "$lte": to},
}}},
{{Key: "$group", Value: bson.M{"_id": "$timeline.event", "n": bson.M{"$sum": 1}}}},
})
if err != nil {
return 0, 0, fmt.Errorf("review outcomes: %w", err)
}
var rows []struct {
ID string `bson:"_id"`
N int64 `bson:"n"`
}
if err := cur.All(ctx, &rows); err != nil {
return 0, 0, fmt.Errorf("decode outcomes: %w", err)
}
for _, r := range rows {
if r.ID == "approved" {
approved = r.N
} else {
changes = r.N
}
}
return approved, changes, nil
}
// TimeLogged sums the developer's logged minutes.
func (s *Store) TimeLogged(ctx context.Context, developerID string, from, to time.Time) (int64, error) {
cur, err := s.tasks().Aggregate(ctx, mongo.Pipeline{
{{Key: "$unwind", Value: "$timeLog"}},
{{Key: "$match", Value: bson.M{
"timeLog.developerId": developerID,
"timeLog.at": bson.M{"$gte": from, "$lte": to},
}}},
{{Key: "$group", Value: bson.M{"_id": nil, "minutes": bson.M{"$sum": "$timeLog.minutes"}}}},
})
if err != nil {
return 0, fmt.Errorf("time logged: %w", err)
}
var rows []struct {
Minutes int64 `bson:"minutes"`
}
if err := cur.All(ctx, &rows); err != nil {
return 0, fmt.Errorf("decode time: %w", err)
}
if len(rows) == 0 {
return 0, nil
}
return rows[0].Minutes, nil
}
// AvgAssignToApproveHours computes the mean lead time from assignment to
// approval over approved tasks (timeline-derived).
func (s *Store) AvgAssignToApproveHours(ctx context.Context, developerID string, from, to time.Time) (float64, error) {
cur, err := s.tasks().Find(ctx, bson.M{
"assignee.userId": developerID,
"status": domain.StatusApproved,
"updatedAt": bson.M{"$gte": from, "$lte": to},
})
if err != nil {
return 0, fmt.Errorf("approved tasks: %w", err)
}
var tasks []domain.Task
if err := cur.All(ctx, &tasks); err != nil {
return 0, fmt.Errorf("decode approved: %w", err)
}
var total time.Duration
var n int
for _, t := range tasks {
var assignedAt, approvedAt time.Time
for _, e := range t.Timeline {
if (e.Event == "claim_approved" || e.Event == "assigned_to_ai") && assignedAt.IsZero() {
assignedAt = e.At
}
if e.Event == "approved" {
approvedAt = e.At
}
}
if !assignedAt.IsZero() && approvedAt.After(assignedAt) {
total += approvedAt.Sub(assignedAt)
n++
}
}
if n == 0 {
return 0, nil
}
return total.Hours() / float64(n), nil
}
// AtomizationLeadTimeHours: mean imported→published lead time per §6.2,
// measured on published tasks against their root's creation.
func (s *Store) AtomizationLeadTimeHours(ctx context.Context, customerIDs []string, from, to time.Time) (float64, error) {
if len(customerIDs) == 0 {
return 0, nil
}
cur, err := s.tasks().Find(ctx, bson.M{
"customerId": bson.M{"$in": customerIDs},
"publishedAt": bson.M{"$gte": from, "$lte": to},
})
if err != nil {
return 0, fmt.Errorf("published tasks: %w", err)
}
var tasks []domain.Task
if err := cur.All(ctx, &tasks); err != nil {
return 0, fmt.Errorf("decode published: %w", err)
}
rootCreated := map[string]time.Time{}
var total time.Duration
var n int
for _, t := range tasks {
created, ok := rootCreated[t.RootID]
if !ok {
root, err := s.TaskByID(ctx, t.RootID)
if err != nil {
continue
}
created = root.CreatedAt
rootCreated[t.RootID] = created
}
if t.PublishedAt.After(created) {
total += t.PublishedAt.Sub(created)
n++
}
}
if n == 0 {
return 0, nil
}
return total.Hours() / float64(n), nil
}
// OpenBoardDepth counts currently published tasks per customer (§6.2).
func (s *Store) OpenBoardDepth(ctx context.Context, customerIDs []string) (map[string]int64, error) {
out := map[string]int64{}
if len(customerIDs) == 0 {
return out, nil
}
cur, err := s.tasks().Aggregate(ctx, mongo.Pipeline{
{{Key: "$match", Value: bson.M{
"customerId": bson.M{"$in": customerIDs},
"status": bson.M{"$in": []domain.TaskStatus{domain.StatusPublished, domain.StatusClaimRequested}},
}}},
{{Key: "$group", Value: bson.M{"_id": "$customerId", "n": bson.M{"$sum": 1}}}},
})
if err != nil {
return nil, fmt.Errorf("board depth: %w", err)
}
var rows []struct {
ID string `bson:"_id"`
N int64 `bson:"n"`
}
if err := cur.All(ctx, &rows); err != nil {
return nil, fmt.Errorf("decode depth: %w", err)
}
for _, r := range rows {
out[r.ID] = r.N
}
return out, nil
}
// Leaderboard: top developers by total bounty (opt-outs filtered by caller).
func (s *Store) Leaderboard(ctx context.Context, from, to time.Time, limit int) ([]GroupTotal, error) {
if limit <= 0 || limit > 50 {
limit = 10
}
cur, err := s.DB.Collection("bountyAwards").Aggregate(ctx, mongo.Pipeline{
{{Key: "$match", Value: bson.M{"awardedAt": bson.M{"$gte": from, "$lte": to}}}},
{{Key: "$group", Value: bson.M{"_id": "$developerId",
"amount": bson.M{"$sum": "$amount"}, "tasks": bson.M{"$sum": 1}}}},
{{Key: "$sort", Value: bson.M{"amount": -1}}},
{{Key: "$limit", Value: limit * 2}}, // headroom for opt-out filtering
})
if err != nil {
return nil, fmt.Errorf("leaderboard: %w", err)
}
out := []GroupTotal{}
if err := cur.All(ctx, &out); err != nil {
return nil, fmt.Errorf("decode leaderboard: %w", err)
}
return out, nil
}
+45
View File
@@ -0,0 +1,45 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// PasswordReset is a one-shot token (§11.22), reaped by TTL index.
type PasswordReset struct {
Token string `bson:"_id"`
UserID string `bson:"userId"`
ExpiresAt time.Time `bson:"expiresAt"`
}
func (s *Store) CreatePasswordReset(ctx context.Context, token, userID string, ttl time.Duration) error {
_, err := s.DB.Collection("passwordResets").InsertOne(ctx, PasswordReset{
Token: token, UserID: userID, ExpiresAt: time.Now().UTC().Add(ttl),
})
if err != nil {
return fmt.Errorf("create password reset: %w", err)
}
return nil
}
// ConsumePasswordReset returns the userId for a live token and deletes it
// atomically (single use).
func (s *Store) ConsumePasswordReset(ctx context.Context, token string) (string, error) {
var pr PasswordReset
err := s.DB.Collection("passwordResets").FindOneAndDelete(ctx, bson.M{
"_id": token,
"expiresAt": bson.M{"$gt": time.Now().UTC()},
}).Decode(&pr)
if errors.Is(err, mongo.ErrNoDocuments) {
return "", ErrNotFound
}
if err != nil {
return "", fmt.Errorf("consume password reset: %w", err)
}
return pr.UserID, nil
}
+17
View File
@@ -71,6 +71,23 @@ func NewConnector(t domain.TicketingType, baseURL, projectKey string, creds Cred
return nil, fmt.Errorf("youtrack requires permanentToken")
}
return &youtrackConnector{baseURL: baseURL, projectKey: projectKey, token: creds["permanentToken"]}, nil
case domain.TicketingWekan:
hasLogin := creds["username"] != "" && creds["password"] != ""
hasToken := creds["token"] != ""
if !hasLogin && !hasToken {
return nil, fmt.Errorf("wekan requires username+password (or a pre-issued token)")
}
if baseURL == "" {
return nil, fmt.Errorf("wekan requires baseUrl")
}
if projectKey == "" {
return nil, fmt.Errorf("wekan requires projectKey (the board id)")
}
return &wekanConnector{
baseURL: baseURL, boardID: projectKey,
username: creds["username"], password: creds["password"],
token: creds["token"],
}, nil
case domain.TicketingDemo:
return &demoConnector{projectKey: projectKey}, nil
default:
+28 -10
View File
@@ -2,9 +2,9 @@ package sync
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
@@ -47,23 +47,41 @@ type jiraIssue struct {
} `json:"fields"`
}
// search runs a JQL query via POST /rest/api/3/search/jql — the replacement
// for the removed GET /rest/api/3/search endpoint (Atlassian CHANGE-2046).
// It uses cursor pagination (nextPageToken/isLast); the legacy startAt/total
// fields no longer exist.
func (j *jiraConnector) search(ctx context.Context, jql, fields string) ([]jiraIssue, error) {
fieldList := strings.Split(fields, ",")
u := j.baseURL + "/rest/api/3/search/jql"
var all []jiraIssue
for startAt := 0; ; {
var page struct {
Issues []jiraIssue `json:"issues"`
Total int `json:"total"`
nextPageToken := ""
for {
reqBody := map[string]any{
"jql": jql,
"fields": fieldList,
"maxResults": 100,
}
u := fmt.Sprintf("%s/rest/api/3/search?jql=%s&fields=%s&maxResults=100&startAt=%d",
j.baseURL, url.QueryEscape(jql), url.QueryEscape(fields), startAt)
if err := getJSON(ctx, j.Authorize, u, &page); err != nil {
if nextPageToken != "" {
reqBody["nextPageToken"] = nextPageToken
}
body, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("jira search: encode request: %w", err)
}
var page struct {
Issues []jiraIssue `json:"issues"`
NextPageToken string `json:"nextPageToken"`
IsLast bool `json:"isLast"`
}
if err := doJSON(ctx, j.Authorize, http.MethodPost, u, body, &page); err != nil {
return nil, fmt.Errorf("jira search: %w", err)
}
all = append(all, page.Issues...)
startAt += len(page.Issues)
if len(page.Issues) == 0 || startAt >= page.Total {
if page.IsLast || page.NextPageToken == "" || len(page.Issues) == 0 {
return all, nil
}
nextPageToken = page.NextPageToken
}
}
+25 -6
View File
@@ -10,6 +10,8 @@ import (
"sync"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"bountyboard/internal/domain"
"bountyboard/internal/files"
"bountyboard/internal/metrics"
@@ -255,7 +257,12 @@ func (m *Manager) SyncCustomer(ctx context.Context, c *domain.Customer) error {
if err != nil {
continue // consultant deleted; reconcile will catch up
}
identity := ticketingIdentity(consultant, c.Ticketing.Type)
// Per-customer mapping (set by the admin on the customer) wins over
// the consultant's global identity; fall back to the global one.
identity := c.Ticketing.ConsultantIdentities[consultantID]
if identity == "" {
identity = ticketingIdentity(consultant, c.Ticketing.Type)
}
if identity == "" {
continue // §5.3: consultant has no linked account in this system
}
@@ -287,12 +294,24 @@ func ticketingIdentity(u *domain.User, t domain.TicketingType) string {
if t == domain.TicketingDemo {
return u.Email // demo accepts any identity
}
ids, _ := u.Extra["ticketingIdentities"].(map[string]any)
if ids == nil {
return ""
// The driver decodes the nested document as bson.D when the field type
// is `any`; JSON-built values arrive as map[string]any. Handle both.
switch ids := u.Extra["ticketingIdentities"].(type) {
case map[string]any:
v, _ := ids[string(t)].(string)
return v
case bson.M:
v, _ := ids[string(t)].(string)
return v
case bson.D:
for _, e := range ids {
if e.Key == string(t) {
v, _ := e.Value.(string)
return v
}
}
}
v, _ := ids[string(t)].(string)
return v
return ""
}
func contentHash(t Ticket) string {
+29
View File
@@ -201,6 +201,35 @@ func TestOrphanFlagging(t *testing.T) {
}
}
// TestTicketingIdentityBSONRoundTrip pins a live-found bug: the driver
// decodes nested extra documents as bson.D (not map[string]any), which used
// to make identity lookups silently return "".
func TestTicketingIdentityBSONRoundTrip(t *testing.T) {
_, st, _, consultant := newSyncStack(t)
ctx := context.Background()
if _, err := st.DB.Collection("users").UpdateOne(ctx,
bson.M{"_id": consultant.ID},
bson.M{"$set": bson.M{"extra": bson.M{"ticketingIdentities": bson.M{
"jira": "cons@corp.example", "wekan": "cons-wekan",
}}}}); err != nil {
t.Fatal(err)
}
fresh, err := st.UserByID(ctx, consultant.ID)
if err != nil {
t.Fatal(err)
}
if got := ticketingIdentity(fresh, domain.TicketingJira); got != "cons@corp.example" {
t.Fatalf("jira identity after BSON round trip = %q", got)
}
if got := ticketingIdentity(fresh, domain.TicketingWekan); got != "cons-wekan" {
t.Fatalf("wekan identity after BSON round trip = %q", got)
}
if got := ticketingIdentity(fresh, domain.TicketingAzure); got != "" {
t.Fatalf("unset identity = %q, want empty", got)
}
}
func TestSyncSkipsConsultantWithoutIdentity(t *testing.T) {
_, st, customer, _ := newSyncStack(t)
ctx := context.Background()
+241
View File
@@ -0,0 +1,241 @@
package sync
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
)
// wekanConnector imports cards from a WeKan board. projectKey is the board
// id; the consultant's identity (users.extra.ticketingIdentities.wekan) is
// their WeKan username — cards assigned to that user (assignees, falling
// back to members) become tasks.
//
// Credentials: {"username": "...", "password": "..."} — the connector logs
// in per sync (WeKan tokens are short-lived); alternatively a pre-issued
// {"token": "...", "userId": "..."} pair is used directly.
type wekanConnector struct {
baseURL string
boardID string
username string
password string
mu sync.Mutex
token string
tokenAt time.Time
}
const wekanTokenTTL = 10 * time.Minute // re-login window for password auth
func (wk *wekanConnector) Authorize(req *http.Request) {
wk.mu.Lock()
defer wk.mu.Unlock()
if wk.token != "" {
req.Header.Set("Authorization", "Bearer "+wk.token)
}
}
// ensureAuth logs in with username/password unless a still-fresh (or
// pre-issued) token is available.
func (wk *wekanConnector) ensureAuth(ctx context.Context) error {
wk.mu.Lock()
haveFresh := wk.token != "" && (wk.password == "" || time.Since(wk.tokenAt) < wekanTokenTTL)
wk.mu.Unlock()
if haveFresh {
return nil
}
body, _ := json.Marshal(map[string]string{"username": wk.username, "password": wk.password})
var out struct {
ID string `json:"id"`
Token string `json:"token"`
}
err := doJSON(ctx, func(*http.Request) {}, http.MethodPost, wk.baseURL+"/users/login", body, &out)
if err != nil {
return fmt.Errorf("wekan login: %w", err)
}
if out.Token == "" {
return fmt.Errorf("wekan login: empty token in response")
}
wk.mu.Lock()
wk.token, wk.tokenAt = out.Token, time.Now()
wk.mu.Unlock()
return nil
}
func (wk *wekanConnector) TestConnection(ctx context.Context) error {
if err := wk.ensureAuth(ctx); err != nil {
return err
}
var board struct {
Title string `json:"title"`
}
if err := getJSON(ctx, wk.Authorize, wk.baseURL+"/api/boards/"+wk.boardID, &board); err != nil {
return fmt.Errorf("wekan board %s: %w", wk.boardID, err)
}
if board.Title == "" {
return fmt.Errorf("wekan board %s: not found or no access", wk.boardID)
}
return nil
}
type wekanBoard struct {
Title string `json:"title"`
Slug string `json:"slug"`
Members []struct {
UserID string `json:"userId"`
} `json:"members"`
}
type wekanCardDetail struct {
ID string `json:"_id"`
Title string `json:"title"`
Description string `json:"description"`
ListID string `json:"listId"`
Assignees []string `json:"assignees"`
Members []string `json:"members"`
ModifiedAt string `json:"modifiedAt"`
DateLastActivity string `json:"dateLastActivity"`
Archived bool `json:"archived"`
}
// resolveUserID maps the consultant's WeKan username to a board member's
// userId.
func (wk *wekanConnector) resolveUserID(ctx context.Context, board *wekanBoard, username string) (string, error) {
for _, m := range board.Members {
var u struct {
Username string `json:"username"`
}
if err := getJSON(ctx, wk.Authorize, wk.baseURL+"/api/users/"+m.UserID, &u); err != nil {
continue // non-admin tokens may not read every user; skip
}
if strings.EqualFold(u.Username, username) {
return m.UserID, nil
}
}
return "", fmt.Errorf("wekan user %q is not a member of board %s", username, wk.boardID)
}
// assignedCards walks lists → cards → card details and returns the cards
// assigned to userID (assignees, else members).
func (wk *wekanConnector) assignedCards(ctx context.Context, userID string) ([]wekanCardDetail, map[string]string, error) {
var lists []struct {
ID string `json:"_id"`
Title string `json:"title"`
}
if err := getJSON(ctx, wk.Authorize, wk.baseURL+"/api/boards/"+wk.boardID+"/lists", &lists); err != nil {
return nil, nil, fmt.Errorf("wekan lists: %w", err)
}
listTitles := map[string]string{}
var out []wekanCardDetail
for _, list := range lists {
listTitles[list.ID] = list.Title
var cards []struct {
ID string `json:"_id"`
}
url := fmt.Sprintf("%s/api/boards/%s/lists/%s/cards", wk.baseURL, wk.boardID, list.ID)
if err := getJSON(ctx, wk.Authorize, url, &cards); err != nil {
return nil, nil, fmt.Errorf("wekan cards of list %s: %w", list.ID, err)
}
for _, c := range cards {
var detail wekanCardDetail
detailURL := fmt.Sprintf("%s/api/boards/%s/lists/%s/cards/%s", wk.baseURL, wk.boardID, list.ID, c.ID)
if err := getJSON(ctx, wk.Authorize, detailURL, &detail); err != nil {
return nil, nil, fmt.Errorf("wekan card %s: %w", c.ID, err)
}
if detail.Archived {
continue
}
assigned := detail.Assignees
if len(assigned) == 0 {
assigned = detail.Members
}
for _, a := range assigned {
if a == userID {
detail.ListID = list.ID
out = append(out, detail)
break
}
}
}
}
return out, listTitles, nil
}
func (wk *wekanConnector) cardTime(c wekanCardDetail) time.Time {
for _, raw := range []string{c.ModifiedAt, c.DateLastActivity} {
if raw == "" {
continue
}
if ts, err := time.Parse(time.RFC3339, raw); err == nil {
return ts
}
}
return time.Time{} // unknown → treat as always-updated
}
func (wk *wekanConnector) FetchUpdated(ctx context.Context, identity string, since time.Time) ([]Ticket, error) {
if err := wk.ensureAuth(ctx); err != nil {
return nil, err
}
var board wekanBoard
if err := getJSON(ctx, wk.Authorize, wk.baseURL+"/api/boards/"+wk.boardID, &board); err != nil {
return nil, fmt.Errorf("wekan board: %w", err)
}
userID, err := wk.resolveUserID(ctx, &board, identity)
if err != nil {
return nil, err
}
cards, listTitles, err := wk.assignedCards(ctx, userID)
if err != nil {
return nil, err
}
slug := board.Slug
if slug == "" {
slug = "board"
}
out := []Ticket{}
for _, c := range cards {
if ts := wk.cardTime(c); !ts.IsZero() && ts.Before(since) {
continue
}
out = append(out, Ticket{
Key: c.ID,
URL: fmt.Sprintf("%s/b/%s/%s/%s", wk.baseURL, wk.boardID, slug, c.ID),
Type: "task", // kanban cards carry no epic/story hierarchy
Title: c.Title,
Description: c.Description,
Raw: map[string]any{
"board": board.Title, "list": listTitles[c.ListID], "cardId": c.ID,
},
})
}
return out, nil
}
func (wk *wekanConnector) ListAssignedKeys(ctx context.Context, identity string) ([]string, error) {
if err := wk.ensureAuth(ctx); err != nil {
return nil, err
}
var board wekanBoard
if err := getJSON(ctx, wk.Authorize, wk.baseURL+"/api/boards/"+wk.boardID, &board); err != nil {
return nil, fmt.Errorf("wekan board: %w", err)
}
userID, err := wk.resolveUserID(ctx, &board, identity)
if err != nil {
return nil, err
}
cards, _, err := wk.assignedCards(ctx, userID)
if err != nil {
return nil, err
}
keys := make([]string, len(cards))
for i, c := range cards {
keys[i] = c.ID
}
return keys, nil
}
+76
View File
@@ -0,0 +1,76 @@
//go:build wekanlive
// Live verification of the WeKan connector against a real instance:
//
// WEKAN_URL=http://127.0.0.1:8546 WEKAN_BOARD=<boardId> \
// WEKAN_USER=… WEKAN_PASS=… WEKAN_IDENTITY=<wekan username> \
// go test -tags=wekanlive -count=1 ./internal/sync/ -run TestWekanLive -v
package sync
import (
"context"
"os"
"testing"
"time"
"bountyboard/internal/domain"
)
func TestWekanLive(t *testing.T) {
url, board := os.Getenv("WEKAN_URL"), os.Getenv("WEKAN_BOARD")
user, pass := os.Getenv("WEKAN_USER"), os.Getenv("WEKAN_PASS")
identity := os.Getenv("WEKAN_IDENTITY")
if url == "" || board == "" || user == "" || pass == "" || identity == "" {
t.Skip("set WEKAN_URL, WEKAN_BOARD, WEKAN_USER, WEKAN_PASS, WEKAN_IDENTITY")
}
conn, err := NewConnector(domain.TicketingWekan, url, board,
Credentials{"username": user, "password": pass})
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if err := conn.TestConnection(ctx); err != nil {
t.Fatalf("TestConnection: %v", err)
}
t.Log("✓ test connection")
tickets, err := conn.FetchUpdated(ctx, identity, time.Unix(0, 0))
if err != nil {
t.Fatalf("FetchUpdated: %v", err)
}
if len(tickets) == 0 {
t.Fatal("expected at least one assigned card")
}
for _, tk := range tickets {
if tk.Key == "" || tk.Title == "" || tk.URL == "" {
t.Errorf("incomplete ticket: %+v", tk)
}
t.Logf("✓ ticket %s %q url=%s list=%v", tk.Key, tk.Title, tk.URL, tk.Raw["list"])
}
keys, err := conn.ListAssignedKeys(ctx, identity)
if err != nil {
t.Fatalf("ListAssignedKeys: %v", err)
}
if len(keys) != len(tickets) {
t.Fatalf("keys (%d) and tickets (%d) disagree", len(keys), len(tickets))
}
t.Logf("✓ %d assigned keys", len(keys))
// recent watermark filters everything out
future, err := conn.FetchUpdated(ctx, identity, time.Now().Add(time.Hour))
if err != nil {
t.Fatal(err)
}
if len(future) != 0 {
t.Fatalf("future since-watermark returned %d tickets", len(future))
}
t.Log("✓ since-filter")
// unknown identity errors
if _, err := conn.FetchUpdated(ctx, "definitely-not-a-member", time.Unix(0, 0)); err == nil {
t.Fatal("unknown identity must error")
}
t.Log("✓ unknown identity rejected")
}
+226
View File
@@ -0,0 +1,226 @@
package sync
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"bountyboard/internal/domain"
)
// fakeWekan implements just enough of the WeKan REST API: login, board with
// members, lists, cards, card details with assignees and timestamps.
func fakeWekan(t *testing.T, logins *atomic.Int64) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
authed := func(w http.ResponseWriter, r *http.Request) bool {
if r.Header.Get("Authorization") != "Bearer wekan-token-1" {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return false
}
return true
}
js := func(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
mux.HandleFunc("POST /users/login", func(w http.ResponseWriter, r *http.Request) {
var req struct{ Username, Password string }
json.NewDecoder(r.Body).Decode(&req)
if req.Username != "syncbot" || req.Password != "hunter2" {
http.Error(w, `{"error":"bad credentials"}`, http.StatusUnauthorized)
return
}
if logins != nil {
logins.Add(1)
}
js(w, map[string]string{"id": "u-syncbot", "token": "wekan-token-1"})
})
mux.HandleFunc("GET /api/boards/board1", func(w http.ResponseWriter, r *http.Request) {
if !authed(w, r) {
return
}
js(w, map[string]any{
"title": "Sprint Board", "slug": "sprint-board",
"members": []map[string]any{
{"userId": "u-clara"}, {"userId": "u-other"}, {"userId": "u-syncbot"},
},
})
})
mux.HandleFunc("GET /api/users/{id}", func(w http.ResponseWriter, r *http.Request) {
if !authed(w, r) {
return
}
names := map[string]string{"u-clara": "clara", "u-other": "othello", "u-syncbot": "syncbot"}
js(w, map[string]string{"username": names[r.PathValue("id")]})
})
mux.HandleFunc("GET /api/boards/board1/lists", func(w http.ResponseWriter, r *http.Request) {
if !authed(w, r) {
return
}
js(w, []map[string]string{{"_id": "l-todo", "title": "Todo"}, {"_id": "l-doing", "title": "Doing"}})
})
mux.HandleFunc("GET /api/boards/board1/lists/{list}/cards", func(w http.ResponseWriter, r *http.Request) {
if !authed(w, r) {
return
}
switch r.PathValue("list") {
case "l-todo":
js(w, []map[string]string{{"_id": "card-a"}, {"_id": "card-b"}, {"_id": "card-d"}})
default:
js(w, []map[string]string{{"_id": "card-c"}})
}
})
cards := map[string]map[string]any{
// assigned to clara, recently modified
"card-a": {"_id": "card-a", "title": "Fix the login flow", "description": "details A",
"assignees": []string{"u-clara"}, "modifiedAt": time.Now().UTC().Format(time.RFC3339)},
// member-fallback assignment, old timestamp
"card-b": {"_id": "card-b", "title": "Old card", "description": "details B",
"assignees": []string{}, "members": []string{"u-clara"},
"modifiedAt": "2020-01-01T00:00:00Z"},
// assigned to someone else
"card-c": {"_id": "card-c", "title": "Not clara's", "description": "details C",
"assignees": []string{"u-other"}, "modifiedAt": time.Now().UTC().Format(time.RFC3339)},
// clara's but archived
"card-d": {"_id": "card-d", "title": "Archived card", "description": "details D",
"assignees": []string{"u-clara"}, "archived": true,
"modifiedAt": time.Now().UTC().Format(time.RFC3339)},
}
mux.HandleFunc("GET /api/boards/board1/lists/{list}/cards/{card}", func(w http.ResponseWriter, r *http.Request) {
if !authed(w, r) {
return
}
c, ok := cards[r.PathValue("card")]
if !ok {
http.NotFound(w, r)
return
}
js(w, c)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func newWekan(t *testing.T, srv *httptest.Server) Connector {
t.Helper()
conn, err := NewConnector(domain.TicketingWekan, srv.URL, "board1",
Credentials{"username": "syncbot", "password": "hunter2"})
if err != nil {
t.Fatal(err)
}
return conn
}
func TestWekanFactoryValidation(t *testing.T) {
if _, err := NewConnector(domain.TicketingWekan, "https://w.example", "board1", Credentials{}); err == nil {
t.Fatal("missing credentials must be rejected")
}
if _, err := NewConnector(domain.TicketingWekan, "", "board1",
Credentials{"username": "u", "password": "p"}); err == nil {
t.Fatal("missing baseUrl must be rejected")
}
if _, err := NewConnector(domain.TicketingWekan, "https://w.example", "",
Credentials{"username": "u", "password": "p"}); err == nil {
t.Fatal("missing board id must be rejected")
}
if _, err := NewConnector(domain.TicketingWekan, "https://w.example", "board1",
Credentials{"token": "tok", "userId": "u1"}); err != nil {
t.Fatalf("pre-issued token must be accepted: %v", err)
}
}
func TestWekanTestConnection(t *testing.T) {
srv := fakeWekan(t, nil)
if err := newWekan(t, srv).TestConnection(context.Background()); err != nil {
t.Fatalf("test connection: %v", err)
}
bad, err := NewConnector(domain.TicketingWekan, srv.URL, "board1",
Credentials{"username": "syncbot", "password": "wrong"})
if err != nil {
t.Fatal(err)
}
if err := bad.TestConnection(context.Background()); err == nil {
t.Fatal("wrong password must fail the connection test")
}
}
func TestWekanFetchUpdated(t *testing.T) {
srv := fakeWekan(t, nil)
conn := newWekan(t, srv)
// since the epoch: clara gets card-a (assignee) and card-b (member
// fallback); card-c is someone else's, card-d is archived
tickets, err := conn.FetchUpdated(context.Background(), "clara", time.Unix(0, 0))
if err != nil {
t.Fatal(err)
}
if len(tickets) != 2 {
t.Fatalf("tickets = %d, want 2 (%+v)", len(tickets), tickets)
}
byKey := map[string]Ticket{}
for _, tk := range tickets {
byKey[tk.Key] = tk
}
a, ok := byKey["card-a"]
if !ok {
t.Fatal("card-a missing")
}
if a.Title != "Fix the login flow" || a.Type != "task" {
t.Fatalf("card-a mapped wrong: %+v", a)
}
if a.URL != srv.URL+"/b/board1/sprint-board/card-a" {
t.Fatalf("card url: %s", a.URL)
}
if a.Raw["list"] != "Todo" || a.Raw["board"] != "Sprint Board" {
t.Fatalf("raw payload: %+v", a.Raw)
}
// recent since-watermark filters out the old card-b
tickets, err = conn.FetchUpdated(context.Background(), "clara", time.Now().Add(-time.Hour))
if err != nil {
t.Fatal(err)
}
if len(tickets) != 1 || tickets[0].Key != "card-a" {
t.Fatalf("since filter: %+v", tickets)
}
// identity casing is forgiving
if _, err := conn.FetchUpdated(context.Background(), "CLARA", time.Unix(0, 0)); err != nil {
t.Fatalf("case-insensitive identity: %v", err)
}
// unknown identity errors clearly
if _, err := conn.FetchUpdated(context.Background(), "nobody", time.Unix(0, 0)); err == nil {
t.Fatal("unknown wekan user must error")
}
}
func TestWekanListAssignedKeysAndTokenReuse(t *testing.T) {
var logins atomic.Int64
srv := fakeWekan(t, &logins)
conn := newWekan(t, srv)
keys, err := conn.ListAssignedKeys(context.Background(), "clara")
if err != nil {
t.Fatal(err)
}
if len(keys) != 2 {
t.Fatalf("keys = %v, want card-a + card-b", keys)
}
// several operations reuse one login token instead of re-authenticating
if _, err := conn.FetchUpdated(context.Background(), "clara", time.Unix(0, 0)); err != nil {
t.Fatal(err)
}
if logins.Load() != 1 {
t.Fatalf("logins = %d, want 1 (token reuse)", logins.Load())
}
}
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env bash
# Live acceptance checklist (§13) against a running, seeded stack with the
# mocks profile. Exercises: demo ticket import → subdivide → extend →
# publish → claim/decline/approve → work → review → award → AI assignment →
# breaker independence.
set -euo pipefail
BASE="${BASE_URL:-http://localhost:8787}"
PASS="demo-pass-123"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
fail() { echo "ACCEPTANCE FAIL: $*" >&2; exit 1; }
ok() { echo "$*"; }
jqr() { python3 -c "import sys,json;d=json.load(sys.stdin);print(eval(sys.argv[1]))" "$1"; }
login() { # $1 email → jar at $TMP/$1.jar, csrf in $TMP/$1.csrf
local jar="$TMP/$1.jar"
curl -fsS -c "$jar" -H 'Content-Type: application/json' \
-d "{\"email\":\"$1\",\"password\":\"$PASS\"}" "$BASE/api/v1/auth/login" -o /dev/null \
|| fail "login $1"
awk '$6=="bb_csrf" {print $7}' "$jar" > "$TMP/$1.csrf"
}
api() { # $1 user, $2 method, $3 path, [$4 json body] → stdout
local jar="$TMP/$1.jar" csrf
csrf=$(cat "$TMP/$1.csrf")
if [ $# -ge 4 ]; then
curl -fsS -b "$jar" -X "$2" -H "X-CSRF-Token: $csrf" \
-H 'Content-Type: application/json' -d "$4" "$BASE$3"
else
curl -fsS -b "$jar" -X "$2" -H "X-CSRF-Token: $csrf" "$BASE$3"
fi
}
echo "1. consultant login + demo tickets imported within one poll interval"
login clara@example.com
for i in $(seq 1 24); do
COUNT=$(api clara@example.com GET /api/v1/consultant/board | jqr "len(d['tasks'])")
[ "$COUNT" -ge 5 ] && break
sleep 5
done
[ "$COUNT" -ge 5 ] || fail "expected ≥5 imported demo tickets, got $COUNT"
ok "demo sync imported $COUNT tickets"
BOARD=$(api clara@example.com GET /api/v1/consultant/board)
ROOT=$(echo "$BOARD" | jqr "[t for t in d['tasks'] if t['status']=='imported'][0]['id']")
ROOT_BUDGET=$(echo "$BOARD" | jqr "[t for t in d['tasks'] if t['id']=='$ROOT'][0]['budget']")
ok "picked imported root $ROOT (budget $ROOT_BUDGET)"
echo "2. subdivide → N children, coefficients sum to 1, editable"
api clara@example.com POST "/api/v1/tasks/$ROOT/subdivide" \
'{"note":"split it","constraints":{"minTasks":3,"maxTasks":3}}' > /dev/null
for i in $(seq 1 30); do
KIDS=$(api clara@example.com GET "/api/v1/consultant/board" | \
jqr "len([t for t in d['tasks'] if t.get('parentId')=='$ROOT'])")
[ "$KIDS" -ge 3 ] && break
sleep 2
done
[ "$KIDS" -ge 3 ] || fail "subdivision produced $KIDS children"
SUM=$(api clara@example.com GET "/api/v1/consultant/board" | \
jqr "round(sum(t['effortCoefficient'] for t in d['tasks'] if t.get('parentId')=='$ROOT' and t['origin']=='subdivided'),4)")
[ "$SUM" = "1.0" ] || fail "children coefficients sum to $SUM"
ok "3 children, coefficient sum exactly 1.0"
CHILD1=$(api clara@example.com GET "/api/v1/consultant/board" | \
jqr "[t for t in d['tasks'] if t.get('parentId')=='$ROOT'][0]['id']")
CHILD1V=$(api clara@example.com GET "/api/v1/tasks/$CHILD1" | jqr "d['task']['version']")
api clara@example.com PATCH "/api/v1/tasks/$CHILD1" \
"{\"version\":$CHILD1V,\"effortCoefficient\":0.5,\"budget\":2000}" > /dev/null
B=$(api clara@example.com GET "/api/v1/tasks/$CHILD1" | jqr "float(d['task']['bounty'])")
[ "$B" = "1000.0" ] || fail "edited bounty = $B, want 1000 (0.5 × 2000)"
ok "edit recomputed bounty = coefficient × budget"
echo "3. extend creates exactly one sibling"
BEFORE=$(api clara@example.com GET /api/v1/consultant/board | \
jqr "len([t for t in d['tasks'] if t['origin']=='extended'])")
api clara@example.com POST "/api/v1/tasks/$ROOT/extend" '{"note":"add CSV presets"}' > /dev/null
for i in $(seq 1 30); do
AFTER=$(api clara@example.com GET /api/v1/consultant/board | \
jqr "len([t for t in d['tasks'] if t['origin']=='extended'])")
[ "$AFTER" -eq $((BEFORE + 1)) ] && break
sleep 2
done
[ "$AFTER" -eq $((BEFORE + 1)) ] || fail "extension count $AFTER (was $BEFORE)"
ok "extension created exactly one sibling"
echo "4. publish → developer board with bounty = coefficient × budget"
api clara@example.com POST "/api/v1/tasks/$CHILD1/publish" '{}' > /dev/null
login dev1@example.com
DEVB=$(api dev1@example.com GET /api/v1/board)
DB=$(echo "$DEVB" | jqr "float([t for t in d['tasks'] if t['id']=='$CHILD1'][0]['bounty'])")
[ "$DB" = "1000.0" ] || fail "published task not on dev board with bounty 1000 (got $DB)"
ok "developer sees published task, bounty 1000"
echo "5. claim → decline → claim → approve → work → changes → approve → award"
api dev1@example.com POST "/api/v1/tasks/$CHILD1/claim" '{"note":"mine!"}' > /dev/null
api clara@example.com POST "/api/v1/tasks/$CHILD1/decline-claim" "{\"developerId\":\"$(api dev1@example.com GET /api/v1/auth/me | jqr "d['user']['id']")\"}" > /dev/null
ST=$(api clara@example.com GET "/api/v1/tasks/$CHILD1" | jqr "d['task']['status']")
[ "$ST" = "published" ] || fail "after decline: $ST"
ok "decline returned task to published"
TOTAL_BEFORE=$(api dev1@example.com GET /api/v1/developer/metrics | jqr "float(d['totalBounty'])")
api dev1@example.com POST "/api/v1/tasks/$CHILD1/claim" '{"note":"please"}' > /dev/null
DEVID=$(api dev1@example.com GET /api/v1/auth/me | jqr "d['user']['id']")
api clara@example.com POST "/api/v1/tasks/$CHILD1/approve-claim" "{\"developerId\":\"$DEVID\"}" > /dev/null
api dev1@example.com POST "/api/v1/tasks/$CHILD1/start" '{}' > /dev/null
api dev1@example.com POST "/api/v1/tasks/$CHILD1/comments" '{"body":"<p>done, see <b>notes</b></p>"}' > /dev/null
api dev1@example.com POST "/api/v1/tasks/$CHILD1/time" '{"minutes":45,"note":"impl"}' > /dev/null
api dev1@example.com POST "/api/v1/tasks/$CHILD1/submit-review" '{}' > /dev/null
api clara@example.com POST "/api/v1/tasks/$CHILD1/review" '{"decision":"request_changes","note":"edge cases"}' > /dev/null
api dev1@example.com POST "/api/v1/tasks/$CHILD1/start" '{}' > /dev/null
api dev1@example.com POST "/api/v1/tasks/$CHILD1/submit-review" '{}' > /dev/null
api clara@example.com POST "/api/v1/tasks/$CHILD1/review" \
'{"decision":"approve","note":"good","checklist":[{"criterion":"works","ok":true}]}' > /dev/null
ST=$(api clara@example.com GET "/api/v1/tasks/$CHILD1" | jqr "d['task']['status']")
[ "$ST" = "approved" ] || fail "after approve: $ST"
TOTAL=$(api dev1@example.com GET /api/v1/developer/metrics | jqr "float(d['totalBounty'])")
DELTA=$(python3 -c "print($TOTAL - $TOTAL_BEFORE)")
[ "$DELTA" = "1000.0" ] || fail "developer metrics delta $DELTA, want 1000"
ok "full lifecycle approved; bountyAwards +1000 reflected in metrics"
echo "6. unassign returns an assigned task to the board"
CHILD2=$(api clara@example.com GET "/api/v1/consultant/board" | \
jqr "[t for t in d['tasks'] if t.get('parentId')=='$ROOT' and t['status']=='atomized'][0]['id']")
api clara@example.com POST "/api/v1/tasks/$CHILD2/publish" '{}' > /dev/null
api dev1@example.com POST "/api/v1/tasks/$CHILD2/claim" '{}' > /dev/null
api clara@example.com POST "/api/v1/tasks/$CHILD2/approve-claim" "{\"developerId\":\"$DEVID\"}" > /dev/null
api clara@example.com POST "/api/v1/tasks/$CHILD2/unassign" '{}' > /dev/null
ST=$(api clara@example.com GET "/api/v1/tasks/$CHILD2" | jqr "d['task']['status']")
[ "$ST" = "published" ] || fail "after unassign: $ST"
ok "unassign returned task to published"
echo "7. AI assignment → §5.2 job → signed callback → in_review"
api clara@example.com POST "/api/v1/tasks/$CHILD2/assign-ai" \
'{"context":{"instructions":"create SUMMARY.md only; do not write code"}}' > /dev/null
for i in $(seq 1 90); do
ST=$(api clara@example.com GET "/api/v1/tasks/$CHILD2" | jqr "d['task']['status']")
[ "$ST" = "in_review" ] && break
sleep 5
done
[ "$ST" = "in_review" ] || fail "AI task status after wait: $ST"
ok "work performer callback moved the task to in_review"
api clara@example.com POST "/api/v1/tasks/$CHILD2/review" '{"decision":"approve","note":"AI ok"}' > /dev/null
ok "consultant reviewed AI work like a human's"
echo "8. breaker independence: stopping the atomizer must not affect the work performer"
sudo -n docker compose --profile mocks stop atomizer-mock > /dev/null 2>&1 \
|| docker compose --profile mocks stop atomizer-mock > /dev/null
CHILD3=$(api clara@example.com GET "/api/v1/consultant/board" | \
jqr "[t for t in d['tasks'] if t['status']=='atomized'][0]['id']")
for i in 1 2 3; do
api clara@example.com POST "/api/v1/tasks/$CHILD3/subdivide" '{"note":"x","confirmReplace":true}' > /dev/null || true
sleep 16
done
HEALTH=$(api clara@example.com GET /api/v1/service-health)
A_OK=$(echo "$HEALTH" | jqr "d['atomizer']['healthy']")
W_OK=$(echo "$HEALTH" | jqr "d['workPerformer']['healthy']")
[ "$A_OK" = "False" ] || fail "atomizer should be down"
[ "$W_OK" = "True" ] || fail "work performer must be unaffected"
BREAKER=$(echo "$HEALTH" | jqr "d['atomizer']['breaker']")
ok "atomizer down (breaker: $BREAKER), work performer healthy — independent"
sudo -n docker compose --profile mocks start atomizer-mock > /dev/null 2>&1 \
|| docker compose --profile mocks start atomizer-mock > /dev/null
ok "atomizer restarted"
echo
echo "ACCEPTANCE PASS"
+187
View File
@@ -0,0 +1,187 @@
// Command seed populates a demo environment (§11.11): one demo customer
// (offline ticketing type), 1 admin, 2 consultants, 6 developers, pool
// memberships, and sample conversations. Idempotent: existing users (by
// email) and customers (by name) are reused, not duplicated.
//
// MONGO_SEED_URI=mongodb://127.0.0.1:27017 go run ./scripts
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"bountyboard/internal/auth"
"bountyboard/internal/config"
"bountyboard/internal/domain"
"bountyboard/internal/store"
"bountyboard/internal/ulid"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
const demoPassword = "demo-pass-123"
func main() {
_ = config.LoadDotEnv(".env")
uri := os.Getenv("MONGO_SEED_URI")
if uri == "" {
uri = "mongodb://127.0.0.1:27017"
}
dbName := os.Getenv("MONGO_DB")
if dbName == "" {
dbName = "bountyboard"
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
client, err := mongo.Connect(options.Client().ApplyURI(uri).
SetServerSelectionTimeout(5 * time.Second))
if err != nil {
log.Fatalf("connect: %v", err)
}
defer client.Disconnect(context.Background())
st := &store.Store{Client: client, DB: client.Database(dbName)}
if err := store.EnsureIndexes(ctx, st.DB); err != nil {
log.Fatalf("indexes: %v", err)
}
user := func(email, name string, roles domain.Roles) *domain.User {
if u, err := st.UserByEmail(ctx, email); err == nil {
fmt.Printf(" exists: %s\n", email)
return u
}
hash, err := auth.HashPassword(demoPassword)
if err != nil {
log.Fatal(err)
}
u := &domain.User{
ID: ulid.New(), Email: email, Name: name, Roles: roles,
Auth: domain.Auth{Local: &domain.LocalAuth{PasswordHash: hash}},
Settings: domain.UserSettings{Theme: "light",
Notifications: domain.NotificationPrefs{Email: true, InApp: true}},
Bio: "Seeded demo account.",
}
if err := st.CreateUser(ctx, u); err != nil {
log.Fatalf("create %s: %v", email, err)
}
fmt.Printf(" created: %s (password %q)\n", email, demoPassword)
return u
}
fmt.Println("Seeding users…")
user("seed-admin@example.com", "Seed Admin", domain.Roles{Admin: true})
clara := user("clara@example.com", "Clara Consultant", domain.Roles{Consultant: true})
carlos := user("carlos@example.com", "Carlos Consultant", domain.Roles{Consultant: true})
devs := []*domain.User{}
devNames := []string{"Dana", "Devon", "Dimitri", "Daria", "Dylan", "Drew"}
for i, n := range devNames {
devs = append(devs, user(fmt.Sprintf("dev%d@example.com", i+1),
n+" Developer", domain.Roles{Developer: true}))
}
fmt.Println("Seeding pools…")
addPool := func(c, d *domain.User) {
if err := st.AddToPool(ctx, c.ID, d.ID); err != nil && err != store.ErrDuplicate {
log.Fatalf("pool: %v", err)
}
}
for _, d := range devs[:4] {
addPool(clara, d)
}
for _, d := range devs[2:] {
addPool(carlos, d)
}
fmt.Println("Seeding demo customer…")
customers, err := st.ListCustomers(ctx, "", true)
if err != nil {
log.Fatal(err)
}
var demoCustomer *domain.Customer
for i := range customers {
if customers[i].Name == "ACME Demo" {
demoCustomer = &customers[i]
}
}
if demoCustomer == nil {
demoCustomer = &domain.Customer{
Name: "ACME Demo",
Ticketing: domain.Ticketing{
Type: domain.TicketingDemo, ProjectKey: "ACME", PollIntervalSec: 30,
},
ConsultantIDs: []string{clara.ID, carlos.ID},
DefaultBudget: 1500,
}
if err := st.CreateCustomer(ctx, demoCustomer); err != nil {
log.Fatal(err)
}
fmt.Println(" created: ACME Demo (demo ticketing — tickets appear within one poll)")
} else {
fmt.Println(" exists: ACME Demo")
}
fmt.Println("Seeding conversations…")
dm, err := st.FindOrCreateDM(ctx, clara.ID, devs[0].ID)
if err != nil {
log.Fatal(err)
}
n, err := st.DB.Collection("messages").CountDocuments(ctx, bson.M{"conversationId": dm.ID})
if err != nil {
log.Fatal(err)
}
if n == 0 {
msgs := []struct {
from *domain.User
body string
}{
{clara, "<p>Hi Dana — I just added you to my pool. The ACME board fills up shortly.</p>"},
{devs[0], "<p>Great, I will grab the <b>CSV importer</b> once it is published.</p>"},
{clara, "<p>Perfect. Ping me here if any acceptance criteria are unclear.</p>"},
}
for _, m := range msgs {
if err := st.InsertMessage(ctx, &store.Message{
ConversationID: dm.ID, SenderID: m.from.ID, Body: m.body,
}); err != nil {
log.Fatal(err)
}
}
fmt.Println(" created: DM Clara ↔ Dana with 3 messages")
}
groups, _ := st.ListConversations(ctx, clara.ID)
hasGroup := false
for _, g := range groups {
if g.Kind == "group" && g.Title == "ACME standup" {
hasGroup = true
}
}
if !hasGroup {
grp := &store.Conversation{
Kind: "group", Title: "ACME standup",
ParticipantIDs: []string{clara.ID, carlos.ID, devs[0].ID, devs[1].ID, devs[2].ID},
}
if err := st.CreateConversation(ctx, grp); err != nil {
log.Fatal(err)
}
if err := st.InsertMessage(ctx, &store.Message{
ConversationID: grp.ID, SenderID: carlos.ID,
Body: "<p>Welcome to the ACME standup channel. Daily updates here, please.</p>",
}); err != nil {
log.Fatal(err)
}
fmt.Println(" created: group 'ACME standup'")
}
fmt.Println("\nSeed complete.")
fmt.Printf("Demo accounts (password %q):\n", demoPassword)
fmt.Println(" seed-admin@example.com (admin)")
fmt.Println(" clara@example.com, carlos@example.com (consultants)")
for i := range devNames {
fmt.Printf(" dev%d@example.com (developer)\n", i+1)
}
}
+39 -6
View File
@@ -1,28 +1,61 @@
#!/usr/bin/env bash
# Curl-based smoke test against a running stack (docker compose up -d).
# Grows with the system; later phases add register→login→board coverage.
# Curl-based smoke test (§13): register → login → healthz → board against a
# running stack (docker compose up -d).
set -euo pipefail
BASE_URL="${BASE_URL:-http://localhost:8787}"
JAR=$(mktemp)
trap 'rm -f "$JAR" /tmp/smoke-*.json' EXIT
fail() { echo "SMOKE FAIL: $*" >&2; exit 1; }
echo "smoke: $BASE_URL"
# healthz must always be 200 while the process is up
# 1. health endpoints
code=$(curl -fsS -o /tmp/smoke-healthz.json -w '%{http_code}' "$BASE_URL/healthz") \
|| fail "healthz unreachable"
[ "$code" = "200" ] || fail "healthz returned $code"
grep -q '"ok"' /tmp/smoke-healthz.json || fail "healthz body unexpected: $(cat /tmp/smoke-healthz.json)"
grep -q '"ok"' /tmp/smoke-healthz.json || fail "healthz body unexpected"
echo " healthz ok"
# readyz reflects dependency state; required deps must be up for the smoke run
code=$(curl -sS -o /tmp/smoke-readyz.json -w '%{http_code}' "$BASE_URL/readyz") \
|| fail "readyz unreachable"
[ "$code" = "200" ] || fail "readyz returned $code: $(cat /tmp/smoke-readyz.json)"
echo " readyz ok"
# metricsz exposes counters
curl -fsS "$BASE_URL/metricsz" | grep -q '"counters"' || fail "metricsz body unexpected"
echo " metricsz ok"
# 2. register a throwaway developer
EMAIL="smoke-$(date +%s)-$RANDOM@example.com"
code=$(curl -sS -c "$JAR" -o /tmp/smoke-reg.json -w '%{http_code}' \
-H 'Content-Type: application/json' \
-d "{\"email\":\"$EMAIL\",\"name\":\"Smoke Test\",\"password\":\"smoke-pass-123\"}" \
"$BASE_URL/api/v1/auth/register")
[ "$code" = "201" ] || fail "register returned $code: $(cat /tmp/smoke-reg.json)"
echo " register ok ($EMAIL)"
# 3. logout, then login again
CSRF=$(awk '$6=="bb_csrf" {print $7}' "$JAR")
curl -fsS -b "$JAR" -X POST -H "X-CSRF-Token: $CSRF" \
"$BASE_URL/api/v1/auth/logout" -o /dev/null || fail "logout failed"
code=$(curl -sS -c "$JAR" -o /tmp/smoke-login.json -w '%{http_code}' \
-H 'Content-Type: application/json' \
-d "{\"email\":\"$EMAIL\",\"password\":\"smoke-pass-123\"}" \
"$BASE_URL/api/v1/auth/login")
[ "$code" = "200" ] || fail "login returned $code"
echo " login ok"
# 4. authenticated me + developer board
curl -fsS -b "$JAR" "$BASE_URL/api/v1/auth/me" | grep -q "$EMAIL" || fail "me missing email"
echo " me ok"
code=$(curl -sS -b "$JAR" -o /tmp/smoke-board.json -w '%{http_code}' "$BASE_URL/api/v1/board")
[ "$code" = "200" ] || fail "board returned $code: $(cat /tmp/smoke-board.json)"
grep -q '"tasks"' /tmp/smoke-board.json || fail "board body unexpected"
echo " board ok"
# 5. pages render
curl -fsS "$BASE_URL/login" | grep -q 'Log in · Bounty Board' || fail "login page broken"
curl -fsS "$BASE_URL/api/docs" | grep -q 'Bounty Board API' || fail "api docs broken"
echo " pages ok"
echo "SMOKE PASS"
+14
View File
@@ -0,0 +1,14 @@
# --- build ---
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod ./
COPY *.go ./
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/atomizer .
# --- runtime ---
FROM alpine:3.21
RUN apk add --no-cache ca-certificates && adduser -S -G nogroup app
USER app
COPY --from=build /out/atomizer /usr/local/bin/atomizer
EXPOSE 8090
ENTRYPOINT ["/usr/local/bin/atomizer"]
BIN
View File
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
module atomizer-mock
go 1.26
+252
View File
@@ -0,0 +1,252 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
)
// llmClient calls Anthropic via the OpenAI-compatible chat-completions
// endpoint (default) or the native Messages API (LLM_API_STYLE=anthropic).
// Plain net/http, no SDK (§9.1).
type llmClient struct {
cfg config
log *slog.Logger
http *http.Client
}
func newLLMClient(cfg config, log *slog.Logger) *llmClient {
return &llmClient{cfg: cfg, log: log, http: &http.Client{Timeout: 110 * time.Second}}
}
const atomizeSystem = `You are an expert software project planner. You split one ticket into small, well-scoped, independently deliverable developer tasks.
Respond with ONLY a JSON object, no prose and no markdown fences, exactly matching:
{"tasks":[{"title":"...","description":"...","acceptanceCriteria":["..."],"effortCoefficient":0.25}],"notes":"short advice for the consultant"}
Rules: between MIN and MAX tasks; every effortCoefficient is a number in (0,1]; all effortCoefficients MUST sum to exactly 1.0; titles are concise; descriptions are self-contained instructions; each task has 1-4 testable acceptance criteria.`
const extendSystem = `You are an expert software project planner. You design exactly ONE additional sibling task that extends the given task's functionality per the extension note.
Respond with ONLY a JSON object, no prose and no markdown fences, exactly matching:
{"task":{"title":"...","description":"...","acceptanceCriteria":["..."],"effortCoefficient":0.4},"notes":"short advice"}
Rules: effortCoefficient is the effort RELATIVE to the source task, a number in (0,2].`
func ticketPrompt(req atomizeRequest) string {
var sb strings.Builder
fmt.Fprintf(&sb, "TICKET: %s\n\nDESCRIPTION:\n%s\n", req.Title, req.Description)
if len(req.AcceptanceCriteria) > 0 {
sb.WriteString("\nACCEPTANCE CRITERIA:\n")
for _, c := range req.AcceptanceCriteria {
fmt.Fprintf(&sb, "- %s\n", c)
}
}
if len(req.Links) > 0 {
fmt.Fprintf(&sb, "\nLINKS: %s\n", strings.Join(req.Links, ", "))
}
for _, a := range req.Attachments {
fmt.Fprintf(&sb, "ATTACHMENT: %s (%s) %s\n", a.Name, a.MimeType, a.URL)
}
return sb.String()
}
func (l *llmClient) atomize(ctx context.Context, req atomizeRequest, minT, maxT int) ([]subTask, string, string, error) {
if l.cfg.apiKey == "" {
return fallbackAtomize(req, minT, maxT), "offline-fallback", "Deterministic split (no ANTHROPIC_API_KEY configured).", nil
}
system := strings.NewReplacer("MIN", fmt.Sprint(minT), "MAX", fmt.Sprint(maxT)).Replace(atomizeSystem)
user := ticketPrompt(req)
if req.SubdivisionNote != "" {
user += "\nCONSULTANT NOTE: " + req.SubdivisionNote
}
var parsed struct {
Tasks []subTask `json:"tasks"`
Notes string `json:"notes"`
}
raw, err := l.completeWithRetry(ctx, system, user, func(text string) error {
if err := json.Unmarshal([]byte(stripFences(text)), &parsed); err != nil {
return err
}
if len(parsed.Tasks) == 0 {
return fmt.Errorf("no tasks in response")
}
for _, t := range parsed.Tasks {
if t.Title == "" || t.EffortCoefficient <= 0 {
return fmt.Errorf("invalid task entry")
}
}
return nil
})
if err != nil {
l.log.Warn("llm atomize failed, using fallback", "err", err)
return fallbackAtomize(req, minT, maxT), "offline-fallback",
"LLM unavailable (" + err.Error() + "); deterministic split.", nil
}
_ = raw
return parsed.Tasks, l.cfg.model, parsed.Notes, nil
}
func (l *llmClient) extend(ctx context.Context, req atomizeRequest) (subTask, string, string, error) {
if l.cfg.apiKey == "" {
return fallbackExtend(req), "offline-fallback", "Deterministic extension (no ANTHROPIC_API_KEY configured).", nil
}
user := ticketPrompt(req) + "\nEXTENSION NOTE (required scope): " + req.ExtensionNote
var parsed struct {
Task subTask `json:"task"`
Notes string `json:"notes"`
}
_, err := l.completeWithRetry(ctx, extendSystem, user, func(text string) error {
if err := json.Unmarshal([]byte(stripFences(text)), &parsed); err != nil {
return err
}
if parsed.Task.Title == "" {
return fmt.Errorf("no task in response")
}
return nil
})
if err != nil {
l.log.Warn("llm extend failed, using fallback", "err", err)
return fallbackExtend(req), "offline-fallback",
"LLM unavailable (" + err.Error() + "); deterministic extension.", nil
}
return parsed.Task, l.cfg.model, parsed.Notes, nil
}
// completeWithRetry runs the chat completion and retries ONCE on parse
// failure with a corrective hint (§9.1).
func (l *llmClient) completeWithRetry(ctx context.Context, system, user string,
validate func(string) error) (string, error) {
text, err := l.complete(ctx, system, user)
if err != nil {
return "", err
}
if vErr := validate(text); vErr == nil {
return text, nil
} else {
l.log.Warn("llm response failed validation, retrying once", "err", vErr)
}
text, err = l.complete(ctx, system,
user+"\n\nIMPORTANT: your previous reply was not valid JSON matching the schema. Reply with ONLY the JSON object.")
if err != nil {
return "", err
}
if vErr := validate(text); vErr != nil {
return "", fmt.Errorf("invalid JSON after retry: %w", vErr)
}
return text, nil
}
func (l *llmClient) complete(ctx context.Context, system, user string) (string, error) {
if l.cfg.apiStyle == "anthropic" {
return l.completeNative(ctx, system, user)
}
return l.completeOpenAI(ctx, system, user)
}
// completeOpenAI uses Anthropic's OpenAI-compatible endpoint.
func (l *llmClient) completeOpenAI(ctx context.Context, system, user string) (string, error) {
body, _ := json.Marshal(map[string]any{
"model": l.cfg.model,
"max_tokens": 4096,
"messages": []map[string]string{
{"role": "system", "content": system},
{"role": "user", "content": user},
},
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
l.cfg.baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+l.cfg.apiKey)
data, err := l.do(req)
if err != nil {
return "", err
}
var out struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(data, &out); err != nil {
return "", fmt.Errorf("decode chat completion: %w", err)
}
if len(out.Choices) == 0 {
return "", fmt.Errorf("no choices in response")
}
return out.Choices[0].Message.Content, nil
}
// completeNative uses the native Anthropic Messages API (flip via
// LLM_API_STYLE=anthropic if the compatibility endpoint misbehaves, §9.1).
func (l *llmClient) completeNative(ctx context.Context, system, user string) (string, error) {
body, _ := json.Marshal(map[string]any{
"model": l.cfg.model,
"max_tokens": 4096,
"system": system,
"messages": []map[string]string{{"role": "user", "content": user}},
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
l.cfg.baseURL+"/messages", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", l.cfg.apiKey)
req.Header.Set("anthropic-version", "2023-06-01")
data, err := l.do(req)
if err != nil {
return "", err
}
var out struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(data, &out); err != nil {
return "", fmt.Errorf("decode messages response: %w", err)
}
for _, c := range out.Content {
if c.Type == "text" {
return c.Text, nil
}
}
return "", fmt.Errorf("no text content in response")
}
func (l *llmClient) do(req *http.Request) ([]byte, error) {
resp, err := l.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("llm api status %d: %.300s", resp.StatusCode, data)
}
return data, nil
}
// stripFences defensively removes markdown code fences and surrounding prose
// (§9.1) by slicing from the first '{' to the last '}'.
func stripFences(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '{'); i >= 0 {
if j := strings.LastIndexByte(s, '}'); j > i {
return s[i : j+1]
}
}
return s
}
+243
View File
@@ -0,0 +1,243 @@
// atomizer-mock is the standalone placeholder Atomization Service (§5.1,
// §9.1). It calls Anthropic through the OpenAI-compatible chat-completions
// endpoint by default, or the native Messages API when
// LLM_API_STYLE=anthropic, and falls back to a deterministic equal split
// when no ANTHROPIC_API_KEY is configured so the whole system works offline.
package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
type config struct {
port string
token string
apiKey string
baseURL string
model string
apiStyle string // openai | anthropic
}
func loadConfig() config {
get := func(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
return config{
port: get("PORT", "8090"),
token: os.Getenv("ATOMIZER_TOKEN"),
apiKey: os.Getenv("ANTHROPIC_API_KEY"),
baseURL: strings.TrimRight(get("ANTHROPIC_OPENAI_BASE_URL", "https://api.anthropic.com/v1"), "/"),
model: get("ANTHROPIC_MODEL", "claude-sonnet-4-6"),
apiStyle: get("LLM_API_STYLE", "openai"),
}
}
type subTask struct {
Title string `json:"title"`
Description string `json:"description"`
AcceptanceCriteria []string `json:"acceptanceCriteria"`
EffortCoefficient float64 `json:"effortCoefficient"`
}
type atomizeRequest struct {
TaskID string `json:"taskId"`
Title string `json:"title"`
Description string `json:"description"`
AcceptanceCriteria []string `json:"acceptanceCriteria"`
Attachments []struct {
Name string `json:"name"`
URL string `json:"url"`
MimeType string `json:"mimeType"`
} `json:"attachments"`
Links []string `json:"links"`
SubdivisionNote string `json:"subdivisionNote"`
ExtensionNote string `json:"extensionNote"`
Constraints *struct {
MinTasks int `json:"minTasks"`
MaxTasks int `json:"maxTasks"`
} `json:"constraints"`
}
type server struct {
cfg config
log *slog.Logger
llm *llmClient
}
func main() {
cfg := loadConfig()
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
s := &server{cfg: cfg, log: log, llm: newLLMClient(cfg, log)}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc("POST /v1/atomize", s.auth(s.handleAtomize))
mux.HandleFunc("POST /v1/extend", s.auth(s.handleExtend))
srv := &http.Server{Addr: ":" + cfg.port, Handler: mux, ReadHeaderTimeout: 10 * time.Second}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
log.Info("atomizer-mock listening", "port", cfg.port,
"llm", map[bool]string{true: "anthropic:" + cfg.apiStyle, false: "fallback (no api key)"}[cfg.apiKey != ""])
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Error("serve", "err", err)
os.Exit(1)
}
}()
<-ctx.Done()
shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(shutCtx)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, code, msg string) {
writeJSON(w, status, map[string]any{"error": map[string]string{"code": code, "message": msg}})
}
func (s *server) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.cfg.token != "" && r.Header.Get("Authorization") != "Bearer "+s.cfg.token {
writeErr(w, http.StatusUnauthorized, "unauthorized", "missing or invalid bearer token")
return
}
next(w, r)
}
}
func (s *server) handleAtomize(w http.ResponseWriter, r *http.Request) {
var req atomizeRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
minT, maxT := 2, 8
if req.Constraints != nil {
if req.Constraints.MinTasks > 0 {
minT = req.Constraints.MinTasks
}
if req.Constraints.MaxTasks > 0 {
maxT = req.Constraints.MaxTasks
}
}
if minT > maxT {
minT = maxT
}
tasks, model, notes, err := s.llm.atomize(r.Context(), req, minT, maxT)
if err != nil {
s.log.Error("atomize failed", "taskId", req.TaskID, "err", err)
writeErr(w, http.StatusBadGateway, "llm_failed", err.Error())
return
}
normalizeSum(tasks)
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "model": model, "notes": notes})
}
func (s *server) handleExtend(w http.ResponseWriter, r *http.Request) {
var req atomizeRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if strings.TrimSpace(req.ExtensionNote) == "" {
writeErr(w, http.StatusBadRequest, "note_required", "extensionNote is required")
return
}
task, model, notes, err := s.llm.extend(r.Context(), req)
if err != nil {
s.log.Error("extend failed", "taskId", req.TaskID, "err", err)
writeErr(w, http.StatusBadGateway, "llm_failed", err.Error())
return
}
if task.EffortCoefficient <= 0 || task.EffortCoefficient > 2 {
task.EffortCoefficient = 0.3
}
writeJSON(w, http.StatusOK, map[string]any{"task": task, "model": model, "notes": notes})
}
// normalizeSum forces coefficients to sum to exactly 1.0.
func normalizeSum(tasks []subTask) {
sum := 0.0
for _, t := range tasks {
sum += t.EffortCoefficient
}
if sum <= 0 {
eq := 1.0 / float64(len(tasks))
for i := range tasks {
tasks[i].EffortCoefficient = eq
}
sum = 1.0
}
total := 0.0
largest := 0
for i := range tasks {
tasks[i].EffortCoefficient = round4(tasks[i].EffortCoefficient / sum)
total += tasks[i].EffortCoefficient
if tasks[i].EffortCoefficient > tasks[largest].EffortCoefficient {
largest = i
}
}
tasks[largest].EffortCoefficient = round4(tasks[largest].EffortCoefficient + 1 - total)
}
func round4(v float64) float64 {
return float64(int64(v*10000+0.5)) / 10000
}
func fallbackAtomize(req atomizeRequest, minT, maxT int) []subTask {
n := (minT + maxT) / 2
if n < 1 {
n = 3
}
out := make([]subTask, n)
eq := round4(1.0 / float64(n))
for i := range out {
out[i] = subTask{
Title: fmt.Sprintf("%s — part %d of %d", req.Title, i+1, n),
Description: fmt.Sprintf("Deterministic offline split %d/%d of:\n\n%s", i+1, n, req.Description),
AcceptanceCriteria: append([]string{}, req.AcceptanceCriteria...),
EffortCoefficient: eq,
}
}
return out
}
func fallbackExtend(req atomizeRequest) subTask {
return subTask{
Title: req.Title + " — extension: " + firstLine(req.ExtensionNote, 60),
Description: "Deterministic offline extension of the source task.\n\nRequested scope: " + req.ExtensionNote,
AcceptanceCriteria: []string{"extension scope implemented: " + firstLine(req.ExtensionNote, 120)},
EffortCoefficient: 0.3,
}
}
func firstLine(s string, max int) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[:i]
}
if len(s) > max {
s = s[:max]
}
return s
}
+13
View File
@@ -0,0 +1,13 @@
FROM node:20-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& npm install -g @anthropic-ai/claude-code
WORKDIR /srv
COPY server.js .
# claude refuses --dangerously-skip-permissions as root: run as the node
# user (uid 1000, matches typical host ownership of the mounted ~/.claude)
RUN mkdir -p /work && chown node:node /work /srv
USER node
ENV HOME=/home/node
EXPOSE 8091
CMD ["node", "server.js"]
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env node
// work-performer: standalone placeholder Work Performer Service (§5.2, §9.2).
// Plain Node http server, no framework. For each job it prepares
// /work/{jobId}/TASK.md and runs the Claude Code CLI; if the CLI is missing
// or fails to start (e.g. no credentials mounted), it produces a simulated
// result so the whole flow stays demonstrable offline. The HTTP contract —
// not this implementation — is the deliverable.
'use strict';
const http = require('http');
const https = require('https');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { execFile, execFileSync } = require('child_process');
const PORT = parseInt(process.env.PORT || '8091', 10);
const TOKEN = process.env.WORK_PERFORMER_TOKEN || '';
const WORK_DIR = process.env.WORK_DIR || '/work';
const PUBLIC_BASE = process.env.PUBLIC_BASE_URL || `http://work-performer:${PORT}`;
const jobs = new Map(); // jobId -> {status, taskId, request, startedAt, finishedAt, cancel}
const queue = [];
let running = false; // single-job concurrency (§9.2)
const log = (msg, extra) =>
console.log(JSON.stringify({ ts: new Date().toISOString(), msg, ...extra }));
function json(res, status, body) {
const data = JSON.stringify(body);
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(data);
}
const errJson = (res, status, code, message) =>
json(res, status, { error: { code, message } });
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
req.on('data', (c) => {
size += c.length;
if (size > 4 << 20) { reject(new Error('body too large')); req.destroy(); return; }
chunks.push(c);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
function download(url, dest) {
return new Promise((resolve, reject) => {
const mod = url.startsWith('https:') ? https : http;
const file = fs.createWriteStream(dest);
mod.get(url, (res) => {
if (res.statusCode !== 200) {
file.close(); fs.rmSync(dest, { force: true });
reject(new Error(`download ${url}: status ${res.statusCode}`));
return;
}
res.pipe(file);
file.on('finish', () => file.close(resolve));
}).on('error', (e) => { file.close(); fs.rmSync(dest, { force: true }); reject(e); });
});
}
function postCallback(urlStr, payload) {
return new Promise((resolve, reject) => {
const body = Buffer.from(JSON.stringify(payload));
const sig = crypto.createHmac('sha256', TOKEN).update(body).digest('hex');
const url = new URL(urlStr);
const mod = url.protocol === 'https:' ? https : http;
const req = mod.request(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': body.length,
'X-Signature': sig,
},
}, (res) => { res.resume(); resolve(res.statusCode); });
req.on('error', reject);
req.end(body);
});
}
function claudeAvailable() {
try {
execFileSync('claude', ['--version'], { timeout: 15000, stdio: 'pipe' });
return true;
} catch (e) {
return false;
}
}
function buildTaskMD(r) {
const lines = [`# ${r.title}`, '', r.description || '', ''];
if ((r.acceptanceCriteria || []).length) {
lines.push('## Acceptance criteria', '');
r.acceptanceCriteria.forEach((c) => lines.push(`- ${c}`));
lines.push('');
}
if ((r.links || []).length) {
lines.push('## Links', '');
r.links.forEach((l) => lines.push(`- ${l}`));
lines.push('');
}
if ((r.attachments || []).length) {
lines.push('## Attachments (downloaded into ./attachments)', '');
r.attachments.forEach((a) => lines.push(`- ${a.name}`));
lines.push('');
}
if (r.context && r.context.instructions) {
lines.push('## Extra instructions', '', r.context.instructions, '');
}
lines.push('Produce your changes in this directory. Write a SUMMARY.md describing what you did.');
return lines.join('\n');
}
function listProducedFiles(dir, before) {
const out = [];
const walk = (d) => {
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
if (entry.name === '.git' || entry.name === 'attachments') continue;
const full = path.join(d, entry.name);
if (entry.isDirectory()) { walk(full); continue; }
const rel = path.relative(dir, full);
if (!before.has(rel) && fs.statSync(full).size <= 20 << 20) out.push(rel);
}
};
walk(dir);
return out.slice(0, 20);
}
async function runJob(jobId) {
const job = jobs.get(jobId);
if (!job || job.status !== 'queued') return;
job.status = 'running';
job.startedAt = new Date().toISOString();
const r = job.request;
const dir = path.join(WORK_DIR, jobId);
let result;
try {
fs.mkdirSync(path.join(dir, 'attachments'), { recursive: true });
// optional repo clone (§9.2)
if (r.context && r.context.repositoryUrl) {
const args = ['clone', '--depth', '1'];
if (r.context.branch) args.push('-b', r.context.branch);
args.push(r.context.repositoryUrl, path.join(dir, 'repo'));
await new Promise((resolve) => {
execFile('git', args, { timeout: 120000 }, (err, _o, stderr) => {
if (err) log('git clone failed', { jobId, err: String(stderr || err) });
resolve();
});
});
}
for (const a of r.attachments || []) {
try {
await download(a.url, path.join(dir, 'attachments', path.basename(a.name)));
} catch (e) { log('attachment download failed', { jobId, name: a.name, err: e.message }); }
}
fs.writeFileSync(path.join(dir, 'TASK.md'), buildTaskMD(r));
const before = new Set(['TASK.md']);
if (claudeAvailable()) {
result = await new Promise((resolve) => {
const child = execFile('claude',
['-p', fs.readFileSync(path.join(dir, 'TASK.md'), 'utf8'),
'--output-format', 'json', '--dangerously-skip-permissions'],
{ cwd: dir, timeout: 30 * 60 * 1000, maxBuffer: 32 << 20 },
(err, stdout, stderr) => {
if (job.cancel) { resolve({ status: 'failed', summary: 'job canceled', log: '' }); return; }
if (err) {
resolve({ status: 'failed', summary: `claude execution failed: ${err.message}`,
log: String(stderr || '').slice(-4000) });
return;
}
let summary = 'Claude Code completed the task.';
try {
const parsed = JSON.parse(stdout);
summary = parsed.result || parsed.summary || summary;
} catch (e) { summary = String(stdout).slice(0, 1000) || summary; }
resolve({ status: 'succeeded', summary: String(summary).slice(0, 4000),
log: String(stdout).slice(-4000) });
});
job.kill = () => child.kill('SIGTERM');
});
} else {
// offline placeholder result keeps the end-to-end flow demonstrable
const summary = `Simulated work performer result (Claude Code CLI not available in this container).\n` +
`Reviewed task "${r.title}" with ${(r.acceptanceCriteria || []).length} acceptance criteria.`;
fs.writeFileSync(path.join(dir, 'SUMMARY.md'),
`# Simulated result\n\n${summary}\n\nThis placeholder proves the §5.2 contract end to end.`);
result = { status: 'succeeded', summary, log: 'claude CLI unavailable; produced simulated SUMMARY.md' };
}
const artifacts = listProducedFiles(dir, before).map((rel) => ({
name: rel.replace(/\//g, '_'),
url: `${PUBLIC_BASE}/artifacts/${jobId}/${encodeURIComponent(rel)}`,
}));
result.artifacts = artifacts;
} catch (e) {
result = { status: 'failed', summary: `job crashed: ${e.message}`, log: '', artifacts: [] };
}
job.status = result.status;
job.finishedAt = new Date().toISOString();
const payload = {
jobId, taskId: r.taskId, status: result.status,
summary: result.summary, artifacts: result.artifacts || [], log: result.log || '',
};
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const code = await postCallback(r.callbackUrl, payload);
log('callback delivered', { jobId, code });
break;
} catch (e) {
log('callback failed', { jobId, attempt, err: e.message });
await new Promise((s) => setTimeout(s, attempt * 2000));
}
}
}
async function pump() {
if (running) return;
const next = queue.shift();
if (!next) return;
running = true;
try { await runJob(next); } finally {
running = false;
setImmediate(pump);
}
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (req.method === 'GET' && url.pathname === '/healthz') {
return json(res, 200, { status: 'ok' });
}
// artifact downloads are unauthenticated within the compose network
const artMatch = url.pathname.match(/^\/artifacts\/([\w]+)\/(.+)$/);
if (req.method === 'GET' && artMatch) {
const file = path.join(WORK_DIR, artMatch[1], decodeURIComponent(artMatch[2]));
if (!file.startsWith(path.join(WORK_DIR, artMatch[1]) + path.sep) || !fs.existsSync(file)) {
return errJson(res, 404, 'not_found', 'artifact not found');
}
res.writeHead(200, { 'Content-Type': 'application/octet-stream' });
return fs.createReadStream(file).pipe(res);
}
if (TOKEN && req.headers.authorization !== `Bearer ${TOKEN}`) {
return errJson(res, 401, 'unauthorized', 'missing or invalid bearer token');
}
if (req.method === 'POST' && url.pathname === '/v1/jobs') {
let body;
try { body = JSON.parse(await readBody(req)); }
catch (e) { return errJson(res, 400, 'bad_request', e.message); }
if (!body.taskId || !body.title || !body.callbackUrl) {
return errJson(res, 400, 'bad_request', 'taskId, title and callbackUrl are required');
}
const jobId = 'wp_' + crypto.randomBytes(8).toString('hex');
jobs.set(jobId, { status: 'queued', taskId: body.taskId, request: body,
startedAt: null, finishedAt: null, cancel: false });
queue.push(jobId);
setImmediate(pump);
log('job queued', { jobId, taskId: body.taskId });
return json(res, 202, { jobId, status: 'queued' });
}
const jobMatch = url.pathname.match(/^\/v1\/jobs\/(wp_[\w]+)$/);
if (jobMatch) {
const job = jobs.get(jobMatch[1]);
if (!job) return errJson(res, 404, 'not_found', 'job not found');
if (req.method === 'GET') {
return json(res, 200, { jobId: jobMatch[1], status: job.status,
startedAt: job.startedAt, finishedAt: job.finishedAt });
}
if (req.method === 'DELETE') { // best-effort cancel (§5.2)
job.cancel = true;
const idx = queue.indexOf(jobMatch[1]);
if (idx >= 0) { queue.splice(idx, 1); job.status = 'failed'; job.finishedAt = new Date().toISOString(); }
if (job.kill) try { job.kill(); } catch (e) { /* already gone */ }
return json(res, 200, { jobId: jobMatch[1], status: 'cancel_requested' });
}
}
errJson(res, 404, 'not_found', 'unknown endpoint');
});
server.listen(PORT, () => log('work-performer listening', { port: PORT, claude: claudeAvailable() }));
process.on('SIGTERM', () => server.close(() => process.exit(0)));
+1 -1
View File
@@ -441,7 +441,7 @@ account** in that system, updated since `lastSyncAt 5 min` (overlap window):
| System | Query mechanism |
|---|---|
| Jira Cloud | `GET /rest/api/3/search?jql=assignee="<email>" AND project=<key> AND updated >= "<ts>"` (Basic auth: email+API token) |
| Jira Cloud | `POST /rest/api/3/search/jql` with body `{jql:'assignee="<email>" AND project=<key> AND updated >= "<ts>"', fields, maxResults}`, cursor-paginated via `nextPageToken`/`isLast` (Basic auth: email+API token). The legacy `GET /rest/api/3/search` was removed by Atlassian — CHANGE-2046. |
| Azure DevOps | `POST …/_apis/wit/wiql?api-version=7.1` with WIQL `[System.AssignedTo] = '<email>' AND [System.TeamProject] = '<project>' AND [System.ChangedDate] >= '<ts>'`, then batch `GET workitems` (PAT auth) |
| YouTrack | `GET /api/issues?query=assignee: <login> project: <key> updated: <ts> .. *` (Bearer permanent token) |
+814 -88
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
+40
View File
@@ -25,6 +25,8 @@ function credsFromForm(type) {
return { organization: val('c-ado-org'), project: val('c-ado-project'), pat: val('c-ado-pat') };
case 'youtrack':
return { permanentToken: val('c-yt-token') };
case 'wekan':
return { username: val('c-wekan-user'), password: val('c-wekan-pass') };
default:
return {};
}
@@ -55,6 +57,36 @@ async function loadConsultants() {
}));
}
// Render one username input per selected consultant, prefilled from the
// customer's saved per-consultant ticketing identity map.
function renderIdentities() {
const wrap = document.getElementById('c-identities-wrap');
const host = document.getElementById('c-identities');
const selectedIds = [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value);
const existing = (editingCustomer && editingCustomer.ticketing.consultantIdentities) || {};
if (!selectedIds.length) { wrap.hidden = true; host.replaceChildren(); return; }
wrap.hidden = false;
host.replaceChildren(...selectedIds.map((id) => {
const u = consultants.find((c) => c.id === id) || { name: id, email: '' };
const row = document.createElement('div');
row.className = 'spread';
row.style.marginTop = '6px';
const label = document.createElement('span');
label.className = 'muted';
label.style.flex = '1';
label.textContent = u.name;
const inp = document.createElement('input');
inp.type = 'text';
inp.dataset.identity = id;
inp.placeholder = 'username in ticketing system';
inp.value = existing[id] || '';
inp.style.flex = '1';
row.append(label, inp);
return row;
}));
}
document.getElementById('c-consultants').addEventListener('change', renderIdentities);
function openCustomerDialog(customer) {
editingCustomer = customer || null;
document.getElementById('customer-dialog-title').textContent =
@@ -71,6 +103,7 @@ function openCustomerDialog(customer) {
[...sel.options].forEach((o) => {
o.selected = customer ? customer.consultantIds.includes(o.value) : false;
});
renderIdentities();
document.getElementById('c-test-result').textContent =
customer && customer.ticketing.hasCredentials ? 'Stored credentials are kept unless you enter new ones.' : '';
dlg.showModal();
@@ -112,6 +145,11 @@ document.getElementById('customer-form').addEventListener('submit', async (e) =>
defaultBudget: parseFloat(val('c-budget')) || 0,
consultantIds: [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value),
};
const identities = {};
document.querySelectorAll('#c-identities input[data-identity]').forEach((inp) => {
if (inp.value.trim()) identities[inp.dataset.identity] = inp.value.trim();
});
body.consultantIdentities = identities;
if (!editingCustomer || credsEntered(type)) body.credentials = credsFromForm(type);
try {
if (editingCustomer) {
@@ -324,6 +362,7 @@ async function loadAudit(reset = true) {
try {
const entity = encodeURIComponent(document.getElementById('audit-entity').value.trim());
if (reset) auditCursor = '';
else if (!auditCursor) return; // already at the last page
const res = await api('GET', `/api/v1/admin/audit-log?entityId=${entity}&cursor=${auditCursor}`);
auditCursor = res.nextCursor;
const rows = res.entries.map((e) => {
@@ -342,6 +381,7 @@ async function loadAudit(reset = true) {
const tbody = document.querySelector('#audit-table tbody');
if (reset) tbody.replaceChildren(...rows);
else tbody.append(...rows);
document.getElementById('audit-more').hidden = !auditCursor;
} catch (e) { showErr(e); }
}
document.getElementById('audit-apply').addEventListener('click', () => loadAudit(true));
+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();
+63 -17
View File
@@ -11,6 +11,12 @@ let tasks = [];
let atomizerUp = true;
const selected = new Set();
// Hold Ctrl/Cmd while dragging an effort slider to rebalance siblings.
let ctrlHeld = false;
window.addEventListener('keydown', (e) => { if (e.key === 'Control' || e.key === 'Meta') ctrlHeld = true; });
window.addEventListener('keyup', (e) => { if (e.key === 'Control' || e.key === 'Meta') ctrlHeld = false; });
window.addEventListener('blur', () => { ctrlHeld = false; });
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&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>' : '';
card.innerHTML = `
<div class="spread">
<h3>${icon} ${esc(root.title)} ${statusBadge(root)} ${orphan}</h3>
<h3>${icon} <a href="/tasks/${root.id}">${esc(root.title)}</a> ${statusBadge(root)} ${orphan}</h3>
<span class="muted">${esc(cust ? cust.name : '')} ${root.external ? '· ' + esc(root.external.key) : ''}</span>
</div>
<p class="muted">${esc((root.description || '').slice(0, 240))}</p>
@@ -115,14 +121,20 @@ function renderChildren(parent, kids, depth) {
wrap.className = 'stack mt';
const subdivided = kids.filter((k) => k.origin === 'subdivided');
const sum = subdivided.reduce((acc, k) => acc + k.effortCoefficient, 0);
const hasExtension = kids.some((k) => k.origin === 'extended');
if (subdivided.length) {
const ind = document.createElement('p');
const bad = !hasExtension && Math.abs(sum - 1) > 0.005;
ind.innerHTML = `Coefficient sum: <strong style="color:${bad ? 'var(--err)' : 'var(--ok)'}">${sum.toFixed(2)}</strong>` +
const subSiblings = []; // {k, slider, coeffEl, bountyEl} for adjustable subdivided rows
let ind = null;
function updateSumIndicator() {
if (!ind) return;
const s = subSiblings.reduce((a, x) => a + parseFloat(x.slider.value), 0);
const bad = !hasExtension && Math.abs(s - 1) > 0.005;
ind.innerHTML = `Coefficient sum: <strong style="color:${bad ? 'var(--err)' : 'var(--ok)'}">${s.toFixed(2)}</strong>` +
(hasExtension ? ' <span class="muted">(extensions allow > 1.00)</span>'
: bad ? ' — should be 1.00' : '');
: bad ? ' — should be 1.00' : '') +
' <span class="muted" style="font-size:0.82rem">· hold Ctrl while dragging to rebalance the others</span>';
}
if (subdivided.length) {
ind = document.createElement('p');
wrap.appendChild(ind);
}
@@ -135,7 +147,7 @@ function renderChildren(parent, kids, depth) {
<div class="spread">
<span>
${k.status === 'atomized' ? `<input type="checkbox" data-sel aria-label="Select ${esc(k.title)} for publishing" ${selected.has(k.id) ? 'checked' : ''}>` : ''}
<strong>${esc(k.title)}</strong> ${ext} ${statusBadge(k)}
<a href="/tasks/${k.id}"><strong>${esc(k.title)}</strong></a> ${ext} ${statusBadge(k)}
</span>
<span>bounty <strong data-bounty>${k.bounty}</strong></span>
</div>
@@ -149,31 +161,62 @@ function renderChildren(parent, kids, depth) {
</label>
<span>
<button class="btn small" data-act="edit">Edit</button>
${['imported', 'atomized'].includes(k.status)
? '<button class="btn small" data-act="subdivide" data-needs-atomizer>Subdivide</button>' : ''}
<button class="btn small" data-act="extend" data-needs-atomizer>Extend</button>
${k.status === 'atomized' ? '<button class="btn small primary" data-act="publish">Publish</button>' : ''}
<button class="btn small" data-act="archive">Archive</button>
</span>
</div>`;
const slider = row.querySelector('input[type=range]');
const coeffEl = row.querySelector('[data-coeff]');
const bountyEl = row.querySelector('[data-bounty]');
if (k.origin === 'subdivided' && !slider.disabled) {
subSiblings.push({ k, slider, coeffEl, bountyEl });
}
let sliderTimer = null;
const round2 = (n) => Math.round(n * 100) / 100;
slider.addEventListener('input', () => {
row.querySelector('[data-coeff]').textContent = parseFloat(slider.value).toFixed(2);
row.querySelector('[data-bounty]').textContent = (slider.value * k.budget).toFixed(2);
const newVal = parseFloat(slider.value);
coeffEl.textContent = newVal.toFixed(2);
bountyEl.textContent = (newVal * k.budget).toFixed(2);
const changed = [{ k, value: newVal }];
// Ctrl/Cmd held: rebalance the other subdivided siblings so the sum
// stays ~1.00 (increase one → the rest shrink proportionally).
if (ctrlHeld && k.origin === 'subdivided') {
const others = subSiblings.filter((s) => s.k.id !== k.id);
const curOthers = others.reduce((a, s) => a + parseFloat(s.slider.value), 0);
const targetOthers = Math.max(0, 1 - newVal);
if (others.length && curOthers > 0) {
const scale = targetOthers / curOthers;
others.forEach((s) => {
const max = parseFloat(s.slider.max);
const nv = round2(Math.max(0.01, Math.min(max, parseFloat(s.slider.value) * scale)));
s.slider.value = nv;
s.coeffEl.textContent = nv.toFixed(2);
s.bountyEl.textContent = (nv * s.k.budget).toFixed(2);
changed.push({ k: s.k, value: nv });
});
}
updateSumIndicator();
}
clearTimeout(sliderTimer);
sliderTimer = setTimeout(async () => {
try {
const res = await api('PATCH', `/api/v1/tasks/${k.id}`, {
effortCoefficient: parseFloat(slider.value), version: k.version,
});
k.version = res.task.version;
k.effortCoefficient = res.task.effortCoefficient;
k.bounty = res.task.bounty;
for (const ch of changed) {
const res = await api('PATCH', `/api/v1/tasks/${ch.k.id}`, {
effortCoefficient: ch.value, version: ch.k.version,
});
ch.k.version = res.task.version;
ch.k.effortCoefficient = res.task.effortCoefficient;
ch.k.bounty = res.task.bounty;
}
render();
} catch (e) {
if (e.status === 409) { toast('Task changed elsewhere — reloading.', 'err'); load(); }
else toast(e.message, 'err');
}
}, 400);
}, 450);
});
const sel = row.querySelector('[data-sel]');
if (sel) {
@@ -183,6 +226,8 @@ function renderChildren(parent, kids, depth) {
});
}
row.querySelector('[data-act=edit]').addEventListener('click', () => openEdit(k, false));
const sd = row.querySelector('[data-act=subdivide]');
if (sd) sd.addEventListener('click', () => openSubdivide(k));
row.querySelector('[data-act=extend]').addEventListener('click', () => openExtend(k));
row.querySelector('[data-act=archive]').addEventListener('click', () => archive(k));
const pub = row.querySelector('[data-act=publish]');
@@ -196,6 +241,7 @@ function renderChildren(parent, kids, depth) {
const grand = childrenOf(k.id);
if (grand.length) wrap.appendChild(renderChildren(k, grand, depth + 1));
});
updateSumIndicator();
return wrap;
}
+10 -3
View File
@@ -4,7 +4,9 @@ import { subscribe, onPollFallback } from '/static/js/ws.js';
const grid = document.getElementById('board-grid');
const errorBox = document.getElementById('error');
let meId = '';
let isDeveloper = false;
let tasks = [];
const customerNames = new Map();
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
@@ -25,6 +27,7 @@ async function load() {
const qs = new URLSearchParams(f).toString();
const res = await api('GET', '/api/v1/board?' + qs);
tasks = res.tasks;
(res.customers || []).forEach((c) => customerNames.set(c.id, c.name));
const sel = document.getElementById('f-customer');
if (sel.options.length === 1) {
res.customers.forEach((c) => sel.add(new Option(c.name, c.id)));
@@ -58,6 +61,7 @@ function renderCard(t) {
<span class="badge accent" style="font-size:1rem"> ${t.bounty}</span>
<span>${ageBadge(t)}</span>
</div>
${customerNames.has(t.customerId) ? `<p class="muted card-project">${esc(customerNames.get(t.customerId))}</p>` : ''}
<h3 class="mt"><a href="/tasks/${t.id}">${esc(t.title)}</a></h3>
<p class="muted">${esc((t.description || '').slice(0, 180))}</p>
<p class="muted">${(t.acceptanceCriteria || []).length} acceptance criteria</p>
@@ -66,9 +70,11 @@ function renderCard(t) {
${myClaim ? '<span class="badge" style="color:var(--ok)">requested by you</span>' : ''}
${others ? `<span class="badge">${others} other request(s)</span>` : ''}
</span>
${myClaim
? '<button class="btn small" data-act="withdraw">Withdraw</button>'
: '<button class="btn primary small" data-act="claim">Request assignment</button>'}
${!isDeveloper
? '<a class="btn small" href="/tasks/' + t.id + '">Open</a>'
: myClaim
? '<button class="btn small" data-act="withdraw">Withdraw</button>'
: '<button class="btn primary small" data-act="claim">Request assignment</button>'}
</div>`;
const claimBtn = card.querySelector('[data-act=claim]');
if (claimBtn) claimBtn.addEventListener('click', () => openClaim(t));
@@ -118,6 +124,7 @@ async function restoreFilters() {
try {
const me = await api('GET', '/api/v1/auth/me');
meId = me.user.id;
isDeveloper = !!(me.user.roles && me.user.roles.developer);
const saved = me.user.extra && me.user.extra.savedBoardFilters;
if (saved) {
const f = JSON.parse(saved);
+92
View File
@@ -0,0 +1,92 @@
// Dependency-free SVG charts (§6.2): a line chart for time series and a
// horizontal bar chart for grouped totals. ~150 lines, no library.
const NS = 'http://www.w3.org/2000/svg';
function el(name, attrs, parent) {
const node = document.createElementNS(NS, name);
Object.entries(attrs || {}).forEach(([k, v]) => node.setAttribute(k, v));
if (parent) parent.appendChild(node);
return node;
}
const fmt = (v) => (Math.abs(v) >= 1000 ? (v / 1000).toFixed(1) + 'k' : String(Math.round(v * 100) / 100));
// lineChart(host, points) — points: [{label: string, value: number}]
export function lineChart(host, points, opts = {}) {
host.replaceChildren();
const W = opts.width || host.clientWidth || 560;
const H = opts.height || 220;
const pad = { l: 48, r: 12, t: 12, b: 28 };
const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%', role: 'img',
'aria-label': opts.label || 'line chart' }, host);
if (!points.length) {
const t = el('text', { x: W / 2, y: H / 2, 'text-anchor': 'middle', fill: 'var(--muted)' }, svg);
t.textContent = 'No data in this period';
return;
}
const max = Math.max(...points.map((p) => p.value), 1);
const x = (i) => pad.l + (i * (W - pad.l - pad.r)) / Math.max(points.length - 1, 1);
const y = (v) => H - pad.b - (v / max) * (H - pad.t - pad.b);
// gridlines + y labels
for (let g = 0; g <= 4; g++) {
const v = (max * g) / 4;
el('line', { x1: pad.l, x2: W - pad.r, y1: y(v), y2: y(v),
stroke: 'var(--border)', 'stroke-width': 1 }, svg);
const t = el('text', { x: pad.l - 6, y: y(v) + 4, 'text-anchor': 'end',
'font-size': 11, fill: 'var(--muted)' }, svg);
t.textContent = fmt(v);
}
// x labels (sparse)
const step = Math.ceil(points.length / 8);
points.forEach((p, i) => {
if (i % step !== 0 && i !== points.length - 1) return;
const t = el('text', { x: x(i), y: H - 8, 'text-anchor': 'middle',
'font-size': 11, fill: 'var(--muted)' }, svg);
t.textContent = p.label;
});
const d = points.map((p, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${y(p.value).toFixed(1)}`).join(' ');
// area fill
el('path', {
d: `${d} L${x(points.length - 1)},${y(0)} L${x(0)},${y(0)} Z`,
fill: 'var(--accent)', opacity: 0.15,
}, svg);
el('path', { d, fill: 'none', stroke: 'var(--accent)', 'stroke-width': 2 }, svg);
points.forEach((p, i) => {
const c = el('circle', { cx: x(i), cy: y(p.value), r: 3, fill: 'var(--accent)' }, svg);
const title = el('title', {}, c);
title.textContent = `${p.label}: ${p.value}`;
});
}
// barChart(host, rows) — rows: [{label, value}], horizontal bars
export function barChart(host, rows, opts = {}) {
host.replaceChildren();
const W = opts.width || host.clientWidth || 560;
const rowH = 26;
const pad = { l: 140, r: 48, t: 6, b: 6 };
const H = pad.t + pad.b + Math.max(rows.length, 1) * rowH;
const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%', role: 'img',
'aria-label': opts.label || 'bar chart' }, host);
if (!rows.length) {
const t = el('text', { x: W / 2, y: H / 2 + 4, 'text-anchor': 'middle', fill: 'var(--muted)' }, svg);
t.textContent = 'No data in this period';
return;
}
const max = Math.max(...rows.map((r) => r.value), 1);
rows.forEach((r, i) => {
const yPos = pad.t + i * rowH;
const label = el('text', { x: pad.l - 8, y: yPos + rowH / 2 + 4,
'text-anchor': 'end', 'font-size': 12, fill: 'var(--text)' }, svg);
label.textContent = r.label.length > 18 ? r.label.slice(0, 17) + '…' : r.label;
const wBar = ((W - pad.l - pad.r) * r.value) / max;
const rect = el('rect', { x: pad.l, y: yPos + 4, width: Math.max(wBar, 2),
height: rowH - 8, fill: 'var(--accent)', rx: 2 }, svg);
const title = el('title', {}, rect);
title.textContent = `${r.label}: ${r.value}`;
const val = el('text', { x: pad.l + Math.max(wBar, 2) + 6, y: yPos + rowH / 2 + 4,
'font-size': 12, fill: 'var(--muted)' }, svg);
val.textContent = fmt(r.value);
});
}
+158
View File
@@ -0,0 +1,158 @@
// Floating messages bubble (bottom-right, every page except /messages):
// unread badge, mini panel with conversation list ↔ thread view and a quick
// plain-text composer. Reuses the conversations API + live WS channel.
import { api } from '/static/js/api.js';
import { subscribe, onPollFallback } from '/static/js/ws.js';
import { attachMentions, mentionHTML } from '/static/js/mention.js';
if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages') {
let meId = '';
let open = false;
let current = null; // conversation object when in thread view
const userCache = new Map();
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
const fab = document.createElement('button');
fab.className = 'chat-fab';
fab.setAttribute('aria-label', 'Messages');
fab.innerHTML = '✉<span class="bell-badge" id="cw-unread" hidden></span>';
document.body.appendChild(fab);
const panel = document.createElement('div');
panel.className = 'chat-panel';
panel.hidden = true;
panel.innerHTML = `
<header>
<span style="display:flex;align-items:center;gap:8px;min-width:0">
<button class="btn small" id="cw-back" hidden aria-label="Back to conversations"> Back</button>
<strong id="cw-title">Messages</strong>
</span>
<span style="display:flex;gap:6px">
<a class="btn small" id="cw-full" href="/messages" aria-label="Open full messages" title="Open full messages"></a>
<button class="btn small" id="cw-close" aria-label="Close" title="Close"></button>
</span>
</header>
<div class="cw-body" id="cw-body"></div>
<footer id="cw-footer" hidden>
<input type="text" id="cw-input" placeholder="Write a message…" aria-label="Message">
<button class="btn small primary" id="cw-send">Send</button>
</footer>`;
document.body.appendChild(panel);
const body = panel.querySelector('#cw-body');
const unreadBadge = fab.querySelector('#cw-unread');
async function userName(id) {
if (!userCache.has(id)) {
try {
const res = await api('GET', `/api/v1/users/${id}/card`);
userCache.set(id, res.card.name);
} catch (e) { userCache.set(id, '…'); }
}
return userCache.get(id);
}
async function refreshUnread() {
try {
const res = await api('GET', '/api/v1/conversations');
const total = res.conversations.reduce((a, c) => a + (c.unread || 0), 0);
unreadBadge.textContent = total > 9 ? '9+' : String(total);
unreadBadge.hidden = total === 0;
return res.conversations;
} catch (e) { return []; }
}
async function showList() {
current = null;
panel.querySelector('#cw-back').hidden = true;
panel.querySelector('#cw-footer').hidden = true;
panel.querySelector('#cw-title').textContent = 'Messages';
const convs = await refreshUnread();
body.replaceChildren(...convs.map((c) => {
const b = document.createElement('button');
b.className = 'cw-conv';
b.innerHTML = `<span>${esc(c.title)} <span class="muted">· ${esc(c.kind)}</span></span>
${c.unread ? `<span class="badge accent">${c.unread}</span>` : ''}`;
b.addEventListener('click', () => showThread(c));
return b;
}));
if (!convs.length) {
body.innerHTML = '<p class="muted" style="padding:10px">No conversations yet — start one from the Messages page.</p>';
}
}
async function renderMsg(m) {
const div = document.createElement('div');
div.className = 'cw-msg' + (m.senderId === meId ? ' own' : '');
div.innerHTML = `<p class="cw-who">${esc(await userName(m.senderId))}</p><div>${m.body || ''}</div>` +
((m.attachments || []).length ? `<p class="muted" style="margin:4px 0 0;font-size:0.75rem">📎 ${m.attachments.length} attachment(s)</p>` : '');
return div;
}
async function showThread(c) {
current = c;
panel.querySelector('#cw-back').hidden = false;
panel.querySelector('#cw-footer').hidden = false;
panel.querySelector('#cw-title').textContent = c.title;
body.replaceChildren();
const res = await api('GET', `/api/v1/conversations/${c.id}/messages?limit=30`);
for (const m of res.messages) body.appendChild(await renderMsg(m));
body.scrollTop = body.scrollHeight;
api('POST', `/api/v1/conversations/${c.id}/read`, {}).catch(() => {});
refreshUnread();
panel.querySelector('#cw-input').focus();
}
async function send() {
const input = panel.querySelector('#cw-input');
const text = input.value.trim();
if (!text || !current) return;
input.value = '';
try {
await api('POST', `/api/v1/conversations/${current.id}/messages`, {
body: '<p>' + mentionHTML(text, input) + '</p>',
});
} catch (e) { input.value = text; }
}
panel.querySelector('#cw-send').addEventListener('click', send);
const cwInput = panel.querySelector('#cw-input');
cwInput.addEventListener('keydown', (e) => {
if (cwInput.dataset.mentionActive === '1') return; // mention picker owns Enter
if (e.key === 'Enter') { e.preventDefault(); send(); }
});
attachMentions(cwInput);
fab.addEventListener('click', async () => {
open = !open;
panel.hidden = !open;
if (open) {
if (!meId) {
try { meId = (await api('GET', '/api/v1/auth/me')).user.id; } catch (e) { /* ignore */ }
}
showList();
}
});
panel.querySelector('#cw-close').addEventListener('click', () => {
open = false;
panel.hidden = true;
});
panel.querySelector('#cw-back').addEventListener('click', showList);
subscribe('chat', async (event, data) => {
if (event !== 'message' || !data) return;
if (open && current && data.conversationId === current.id) {
body.appendChild(await renderMsg(data));
body.scrollTop = body.scrollHeight;
api('POST', `/api/v1/conversations/${current.id}/read`, {}).catch(() => {});
} else if (open && !current) {
showList();
} else {
refreshUnread();
}
});
onPollFallback(refreshUnread);
refreshUnread();
}
+19
View File
@@ -0,0 +1,19 @@
import { api } from '/static/js/api.js';
document.getElementById('forgot-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorBox = document.getElementById('error');
const okBox = document.getElementById('ok');
errorBox.textContent = '';
okBox.textContent = '';
try {
await api('POST', '/api/v1/auth/forgot', {
email: document.getElementById('email').value.trim(),
});
okBox.textContent = 'If an account with that email exists, a reset link is on its way.';
} catch (err) {
errorBox.textContent = err.code === 'smtp_disabled'
? 'Email is not configured on this server — ask an administrator to reset your password.'
: err.message;
}
});
+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]));
}
+437
View File
@@ -0,0 +1,437 @@
import { api, toast } from '/static/js/api.js';
import { subscribe, send as wsSend, onPollFallback } from '/static/js/ws.js';
import { attachMentions } from '/static/js/mention.js';
const errorBox = document.getElementById('error');
const convList = document.getElementById('conv-list');
const msgHost = document.getElementById('chat-messages');
const form = document.getElementById('chat-form');
const composer = document.getElementById('chat-composer');
let meId = '';
let conversations = [];
let current = null;
let oldestCursor = '';
let pendingAttachments = [];
const userCache = new Map();
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
async function userName(id) {
if (!userCache.has(id)) {
try {
const res = await api('GET', `/api/v1/users/${id}/card`);
userCache.set(id, res.card.name);
} catch (e) { userCache.set(id, '…'); }
}
return userCache.get(id);
}
async function loadConversations() {
try {
const res = await api('GET', '/api/v1/conversations');
conversations = res.conversations;
renderConvList();
const want = new URLSearchParams(location.search).get('c');
if (!current && want) {
const c = conversations.find((x) => x.id === want);
if (c) openConversation(c);
}
} catch (e) { errorBox.textContent = e.message; }
}
function renderConvList() {
convList.replaceChildren(...conversations.map((c) => {
const li = document.createElement('li');
li.className = current && current.id === c.id ? 'active' : '';
li.innerHTML = `
<button type="button" class="conv-item">
<span>${esc(c.title)} <span class="muted">· ${esc(c.kind)}</span></span>
${c.unread ? `<span class="badge accent">${c.unread}</span>` : ''}
</button>`;
li.querySelector('button').addEventListener('click', () => openConversation(c));
return li;
}));
if (!conversations.length) {
convList.innerHTML = '<li class="muted">No conversations yet.</li>';
}
}
async function openConversation(c) {
current = c;
oldestCursor = '';
noMoreOlder = false;
document.getElementById('chat-title').textContent = c.title;
const mbtn = document.getElementById('chat-members');
mbtn.hidden = !(c.kind !== 'dm' && c.creatorId && c.creatorId === meId);
form.hidden = false;
msgHost.replaceChildren();
renderConvList();
await loadMessages(false);
await api('POST', `/api/v1/conversations/${c.id}/read`, {}).catch(() => {});
c.unread = 0;
renderConvList();
history.replaceState(null, '', '/messages?c=' + c.id);
}
const MSG_PAGE = 40;
async function loadMessages(prepend) {
const res = await api('GET',
`/api/v1/conversations/${current.id}/messages?limit=${MSG_PAGE}&cursor=${prepend ? oldestCursor : ''}`);
const msgs = res.messages;
if (prepend && msgs.length < MSG_PAGE) noMoreOlder = true;
if (msgs.length) oldestCursor = msgs[0].id;
const nodes = await Promise.all(msgs.map(renderMessage));
if (prepend) {
// preserve the scroll position so the view doesn't jump when older
// messages are inserted above the current viewport
const before = msgHost.scrollHeight;
const top = msgHost.scrollTop;
msgHost.prepend(...nodes);
msgHost.scrollTop = top + (msgHost.scrollHeight - before);
} else {
msgHost.append(...nodes);
msgHost.scrollTop = msgHost.scrollHeight;
}
}
// load older history when the user scrolls near the top
let loadingOlder = false;
let noMoreOlder = false;
msgHost.addEventListener('scroll', async () => {
if (msgHost.scrollTop > 60 || loadingOlder || noMoreOlder || !current) return;
loadingOlder = true;
try { await loadMessages(true); } finally { loadingOlder = false; }
});
// ULID ids embed a 48-bit ms timestamp in the first 10 chars.
function ulidTime(id) {
const A = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
let ms = 0;
for (const ch of String(id).slice(0, 10).toUpperCase()) ms = ms * 32 + A.indexOf(ch);
return new Date(ms);
}
async function renderMessage(m) {
const div = document.createElement('div');
div.className = 'chat-msg' + (m.senderId === meId ? ' own' : '');
const atts = (m.attachments || []).map((a) => a.isImage
? `<img src="/files/${a.fileId}" alt="${esc(a.name)}" class="chat-img" data-lightbox loading="lazy">`
: `<a class="btn small" href="/files/${a.fileId}">📄 ${esc(a.name)}</a>`).join(' ');
div.innerHTML = `
<p class="muted" style="margin:0">${esc(await userName(m.senderId))}
· ${ulidTime(m.id).toLocaleString()}</p>
<div>${m.body || ''}</div>
${atts ? `<div class="mt">${atts}</div>` : ''}`;
div.querySelectorAll('[data-lightbox]').forEach((img) => {
img.addEventListener('click', () => {
document.getElementById('lightbox-img').src = img.src;
document.getElementById('lightbox').showModal();
});
});
return div;
}
document.getElementById('lightbox').addEventListener('click', () => {
document.getElementById('lightbox').close();
});
// ---- composer ----
document.querySelectorAll('[data-cmd]').forEach((btn) => {
btn.addEventListener('click', () => {
composer.focus();
document.execCommand(btn.dataset.cmd, false, null);
});
});
composer.addEventListener('keydown', (e) => {
if (composer.dataset.mentionActive === '1') return; // mention picker owns Enter
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
form.requestSubmit();
}
});
attachMentions(composer);
let typingTimer = null;
composer.addEventListener('input', () => {
if (!current) return;
clearTimeout(typingTimer);
typingTimer = setTimeout(() => {
wsSend('chat', 'typing', { conversationId: current.id });
}, 250);
});
async function uploadFiles(fileList) {
for (const file of fileList) {
const fd = new FormData();
fd.append('file', file);
try {
const res = await api('POST', '/api/v1/files', fd);
pendingAttachments.push(res);
renderPending();
} catch (e) { toast(`Upload failed: ${e.message}`, 'err'); }
}
}
function renderPending() {
const host = document.getElementById('chat-attachments');
host.replaceChildren(...pendingAttachments.map((a, i) => {
const chip = document.createElement('span');
chip.className = 'badge';
chip.innerHTML = `${a.isImage ? '🖼' : '📄'} ${esc(a.name)} <button type="button" class="btn small ghost" aria-label="Remove attachment">×</button>`;
chip.querySelector('button').addEventListener('click', () => {
pendingAttachments.splice(i, 1);
renderPending();
});
return chip;
}));
}
document.getElementById('chat-file').addEventListener('change', (e) => {
uploadFiles(e.target.files);
e.target.value = '';
});
composer.addEventListener('dragover', (e) => e.preventDefault());
composer.addEventListener('drop', (e) => {
e.preventDefault();
if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files);
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (!current) return;
const body = composer.innerHTML;
if (!composer.textContent.trim() && !pendingAttachments.length) return;
try {
await api('POST', `/api/v1/conversations/${current.id}/messages`, {
body, attachments: pendingAttachments.map((a) => a.fileId),
});
composer.innerHTML = '';
pendingAttachments = [];
renderPending();
} catch (err) { toast(err.message, 'err'); }
});
// ---- live events ----
const typingNames = new Map();
subscribe('chat', async (event, data) => {
if (event === 'message' && data) {
if (current && data.conversationId === current.id) {
const nearBottom = msgHost.scrollHeight - msgHost.scrollTop - msgHost.clientHeight < 120;
msgHost.appendChild(await renderMessage(data));
if (nearBottom) msgHost.scrollTop = msgHost.scrollHeight;
api('POST', `/api/v1/conversations/${current.id}/read`, {}).catch(() => {});
} else {
loadConversations();
}
}
if (event === 'typing' && data && current && data.conversationId === current.id) {
const name = await userName(data.userId);
typingNames.set(data.userId, Date.now());
document.getElementById('typing-indicator').textContent = `${name} is typing…`;
setTimeout(() => {
if (Date.now() - (typingNames.get(data.userId) || 0) >= 2900) {
document.getElementById('typing-indicator').textContent = '';
}
}, 3000);
}
});
onPollFallback(() => {
loadConversations();
if (current) {
msgHost.replaceChildren();
oldestCursor = '';
loadMessages(false);
}
});
// ---- new conversation dialog ----
const dlg = document.getElementById('newconv-dialog');
const selected = new Map();
document.getElementById('conv-new').addEventListener('click', () => {
selected.clear();
renderSelected();
document.getElementById('nc-search').value = '';
dlg.showModal();
searchPeople(); // pre-fill the fixed-height list so the dialog never jumps
});
document.getElementById('nc-cancel').addEventListener('click', () => dlg.close());
document.getElementById('nc-kind').addEventListener('change', (e) => {
document.getElementById('nc-title-wrap').hidden = e.target.value === 'dm';
document.getElementById('nc-customer-wrap').hidden = e.target.value !== 'project';
});
let searchTimer = null;
async function searchPeople() {
const q = document.getElementById('nc-search').value.trim();
const res = await api('GET', `/api/v1/users?q=${encodeURIComponent(q)}`);
const host = document.getElementById('nc-results');
const rows = res.users.filter((u) => u.id !== meId && !selected.has(u.id)).map((u) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'nc-row';
b.setAttribute('role', 'option');
b.textContent = `${u.name}${u.email}`;
b.addEventListener('click', () => {
selected.set(u.id, u);
renderSelected();
searchPeople(); // refresh list so picked people disappear
});
return b;
});
host.replaceChildren(...rows);
if (!rows.length) {
host.innerHTML = '<p class="nc-empty">No matches — try a name or email.</p>';
}
}
document.getElementById('nc-search').addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(searchPeople, 250);
});
function renderSelected() {
const host = document.getElementById('nc-selected');
host.replaceChildren(...[...selected.values()].map((u) => {
const chip = document.createElement('span');
chip.className = 'badge';
chip.textContent = u.name + ' ×';
chip.style.cursor = 'pointer';
chip.addEventListener('click', () => { selected.delete(u.id); renderSelected(); });
return chip;
}));
}
document.getElementById('newconv-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
const kind = document.getElementById('nc-kind').value;
const res = await api('POST', '/api/v1/conversations', {
kind,
title: document.getElementById('nc-title').value.trim(),
customerId: document.getElementById('nc-customer').value.trim(),
participantIds: [...selected.keys()],
});
dlg.close();
await loadConversations();
const conv = conversations.find((c) => c.id === res.conversation.id);
if (conv) openConversation(conv);
} catch (err) { toast(err.message, 'err'); }
});
// ---- message search ----
const convSearch = document.getElementById('conv-search');
let msgSearchTimer = null;
async function runMessageSearch(q) {
const list = document.getElementById('conv-list');
const results = document.getElementById('search-results');
if (q.length < 2) { results.hidden = true; results.replaceChildren(); list.hidden = false; return; }
try {
const res = await api('GET', `/api/v1/messages/search?q=${encodeURIComponent(q)}`);
list.hidden = true; results.hidden = false;
results.replaceChildren(...res.results.map((r) => {
const li = document.createElement('li');
const b = document.createElement('button');
b.type = 'button';
b.className = 'conv-item';
b.style.cssText = 'flex-direction:column;align-items:flex-start;gap:2px';
b.innerHTML = `<span>${esc(r.conversationTitle)}</span>
<span class="muted" style="font-size:0.82rem">${esc(r.snippet)}</span>`;
b.addEventListener('click', () => {
const c = conversations.find((x) => x.id === r.conversationId);
if (c) {
convSearch.value = '';
results.hidden = true; list.hidden = false;
openConversation(c);
}
});
li.appendChild(b);
return li;
}));
if (!res.results.length) {
results.innerHTML = '<li class="muted" style="padding:10px">No matching messages.</li>';
}
} catch (e) { /* ignore transient search errors */ }
}
convSearch.addEventListener('input', () => {
clearTimeout(msgSearchTimer);
const q = convSearch.value.trim();
msgSearchTimer = setTimeout(() => runMessageSearch(q), 250);
});
// ---- manage members (group creator only) ----
const membersDlg = document.getElementById('members-dialog');
let mmMemberIds = new Set();
async function renderMembers() {
const res = await api('GET', `/api/v1/conversations/${current.id}/members`);
mmMemberIds = new Set(res.members.map((u) => u.id));
const host = document.getElementById('mm-list');
host.replaceChildren(...res.members.map((u) => {
const li = document.createElement('li');
li.className = 'conv-item';
li.style.justifyContent = 'space-between';
li.innerHTML = `<span>${esc(u.name)} ${u.isCreator ? '<span class="badge accent">creator</span>' : ''}
<br><span class="muted">${esc(u.email)}</span></span>`;
if (res.canManage && !u.isCreator) {
const rm = document.createElement('button');
rm.className = 'btn small';
rm.textContent = 'Remove';
rm.addEventListener('click', async () => {
try {
await api('DELETE', `/api/v1/conversations/${current.id}/members/${u.id}`);
await renderMembers();
} catch (e) { toast(e.message, 'err'); }
});
li.appendChild(rm);
}
return li;
}));
document.getElementById('mm-add-wrap').hidden = !res.canManage;
}
let mmTimer = null;
async function mmSearch() {
const q = document.getElementById('mm-search').value.trim();
const res = await api('GET', `/api/v1/users?q=${encodeURIComponent(q)}`);
const host = document.getElementById('mm-results');
const rows = res.users.filter((u) => !mmMemberIds.has(u.id)).map((u) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'nc-row';
b.textContent = `${u.name}${u.email}`;
b.addEventListener('click', async () => {
try {
await api('POST', `/api/v1/conversations/${current.id}/members`, { userId: u.id });
document.getElementById('mm-search').value = '';
host.replaceChildren();
await renderMembers();
} catch (e) { toast(e.message, 'err'); }
});
return b;
});
host.replaceChildren(...rows);
}
document.getElementById('chat-members').addEventListener('click', async () => {
if (!current) return;
document.getElementById('mm-search').value = '';
document.getElementById('mm-results').replaceChildren();
try { await renderMembers(); membersDlg.showModal(); }
catch (e) { toast(e.message, 'err'); }
});
document.getElementById('mm-close').addEventListener('click', () => membersDlg.close());
document.getElementById('mm-search').addEventListener('input', () => {
clearTimeout(mmTimer);
mmTimer = setTimeout(mmSearch, 250);
});
(async () => {
try {
const me = await api('GET', '/api/v1/auth/me');
meId = me.user.id;
} catch (e) { /* page guard handles */ }
loadConversations();
})();
+113
View File
@@ -0,0 +1,113 @@
import { api, toast } from '/static/js/api.js';
import { lineChart, barChart } from '/static/js/charts.js';
const errorBox = document.getElementById('error');
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
let me = null;
function rangeQS() {
const from = document.getElementById('m-from').value;
const to = document.getElementById('m-to').value;
const p = new URLSearchParams();
if (from) p.set('from', from);
if (to) p.set('to', to);
return p;
}
function card(label, value, hint) {
return `<div class="card stat"><p class="stat-label">${esc(label)}</p>
<p class="stat-value">${esc(value)}</p>
${hint ? `<p class="hint">${esc(hint)}</p>` : ''}</div>`;
}
const weekLabel = (iso) => {
const d = new Date(iso);
return `${d.getMonth() + 1}/${d.getDate()}`;
};
async function loadDeveloper() {
const res = await api('GET', '/api/v1/developer/metrics?' + rangeQS());
document.getElementById('dev-metrics').hidden = false;
document.getElementById('dev-cards').innerHTML =
card('Total bounty earned', res.totalBounty) +
card('Tasks completed', res.tasksCompleted) +
card('Approval rate', `${Math.round(res.approvalRate * 100)}%`,
`${res.approved} approved · ${res.changesRequested} change requests`) +
card('Avg assigned → approved', `${res.avgAssignToApproveHours.toFixed(1)} h`) +
card('Time logged', `${Math.floor(res.timeLoggedMinutes / 60)}h ${res.timeLoggedMinutes % 60}m`);
lineChart(document.getElementById('dev-weekly'),
res.weekly.map((p) => ({ label: weekLabel(p.week), value: p.amount })),
{ label: 'weekly earnings' });
barChart(document.getElementById('dev-customers'),
res.perCustomer.map((g) => ({ label: g.key, value: g.amount })),
{ label: 'earnings per customer' });
document.getElementById('m-csv').href = '/api/v1/developer/metrics?format=csv&' + rangeQS();
}
async function loadConsultant() {
const qs = rangeQS();
const cust = document.getElementById('m-customer').value;
if (cust) qs.set('customerId', cust);
const res = await api('GET', '/api/v1/consultant/metrics?' + qs);
document.getElementById('cons-metrics').hidden = false;
const sel = document.getElementById('m-customer');
if (sel.options.length === 1) {
res.customers.forEach((c) => sel.add(new Option(c.name, c.id)));
}
document.getElementById('cons-cards').innerHTML =
card('Bounty awarded', res.totalBounty) +
card('Tasks completed', res.tasksCompleted) +
card('Atomization lead time', `${res.atomizationLeadHours.toFixed(1)} h`, 'imported → published') +
card('Open board depth', res.openBoardDepth, 'published, unclaimed tasks');
lineChart(document.getElementById('cons-weekly'),
res.weekly.map((p) => ({ label: weekLabel(p.week), value: p.amount })),
{ label: 'weekly awards' });
barChart(document.getElementById('cons-devs'),
res.perDeveloper.map((g) => ({ label: g.key, value: g.amount })),
{ label: 'per developer' });
barChart(document.getElementById('cons-customers'),
res.perCustomer.map((g) => ({ label: g.key, value: g.amount })),
{ label: 'per customer' });
document.getElementById('m-csv').href = '/api/v1/consultant/metrics?format=csv&' + qs;
}
async function loadLeaderboard() {
const res = await api('GET', '/api/v1/leaderboard?' + rangeQS());
const tbody = document.querySelector('#leaderboard tbody');
tbody.replaceChildren(...res.leaderboard.map((row, i) => {
const tr = document.createElement('tr');
tr.innerHTML = `<td>${i + 1}</td><td>${esc(row.name)}</td><td>${row.tasks}</td><td>◈ ${row.amount}</td>`;
return tr;
}));
if (!res.leaderboard.length) {
tbody.innerHTML = '<tr><td colspan="4" class="muted">No bounties awarded yet.</td></tr>';
}
}
async function loadAll() {
errorBox.textContent = '';
try {
if (me.user.roles.developer) await loadDeveloper();
if (me.user.roles.consultant || me.user.roles.admin) await loadConsultant();
await loadLeaderboard();
} catch (e) { errorBox.textContent = e.message; }
}
document.getElementById('m-apply').addEventListener('click', loadAll);
document.getElementById('m-customer').addEventListener('change', loadConsultant);
document.getElementById('lb-optout').addEventListener('change', async (e) => {
try {
await api('PATCH', '/api/v1/profile', { settings: { leaderboardOptOut: e.target.checked } });
toast(e.target.checked ? 'You are hidden from the leaderboard.' : 'You are visible on the leaderboard.', 'ok');
loadLeaderboard();
} catch (err) { toast(err.message, 'err'); }
});
(async () => {
me = await api('GET', '/api/v1/auth/me');
document.getElementById('lb-optout').checked = !!me.user.settings.leaderboardOptOut;
loadAll();
})();
+4 -1
View File
@@ -16,9 +16,12 @@ const themeBtn = document.getElementById('nav-theme');
if (themeBtn) {
themeBtn.addEventListener('click', async () => {
const el = document.documentElement;
const next = el.dataset.theme === 'dark' ? 'light' : 'dark';
const themes = (el.dataset.themes || 'light,dark').split(',');
const cur = themes.indexOf(el.dataset.theme);
const next = themes[(cur + 1) % themes.length];
el.dataset.theme = next;
try { localStorage.setItem('theme', next); } catch (e) { /* ignore */ }
toast('Theme: ' + next);
if (document.body.dataset.loggedIn === '1') {
try {
await api('PATCH', '/api/v1/profile', { settings: { theme: next } });
+5 -1
View File
@@ -56,12 +56,16 @@ form.addEventListener('submit', async (e) => {
links,
},
extra,
settings: { theme: document.getElementById('p-theme').value },
settings: {
theme: document.getElementById('p-theme').value,
navLayout: document.getElementById('p-nav').value,
},
});
version = res.user.version;
const theme = res.user.settings.theme;
document.documentElement.dataset.theme = theme;
try { localStorage.setItem('theme', theme); } catch (err) { /* ignore */ }
document.body.dataset.nav = res.user.settings.navLayout || 'side';
okBox.textContent = 'Profile saved.';
} catch (err) {
if (err.status === 409) {
+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();
+19
View File
@@ -0,0 +1,19 @@
import { api } from '/static/js/api.js';
document.getElementById('reset-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorBox = document.getElementById('error');
errorBox.textContent = '';
const pw = document.getElementById('new').value;
if (pw !== document.getElementById('confirm').value) {
errorBox.textContent = 'Passwords do not match.';
return;
}
try {
await api('POST', '/api/v1/auth/reset', {
token: new URLSearchParams(window.location.search).get('token') || '',
newPassword: pw,
});
window.location.href = '/login';
} catch (err) { errorBox.textContent = err.message; }
});
+1 -1
View File
@@ -26,7 +26,7 @@ async function load() {
return card;
}));
if (!res.tasks.length) {
host.innerHTML = '<p class="muted">Nothing waiting for review. 🎉</p>';
host.innerHTML = '<p class="muted">Nothing waiting for review.</p>';
}
} catch (e) { errorBox.textContent = e.message; }
}
+104
View File
@@ -0,0 +1,104 @@
// Global keyboard shortcuts (§10): g b → board, g m → messages, / → search.
// Plus profile hover cards (§11.13) for any [data-user-card] element.
import { api } from '/static/js/api.js';
let pendingG = false;
let gTimer = null;
function typingTarget(e) {
const t = e.target;
return t.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(t.tagName);
}
document.addEventListener('keydown', (e) => {
if (typingTarget(e) || e.ctrlKey || e.metaKey || e.altKey) return;
if (e.key === '/') {
const search = document.querySelector('input[type=search]');
if (search) { e.preventDefault(); search.focus(); }
return;
}
if (pendingG) {
pendingG = false;
clearTimeout(gTimer);
const map = { b: '/board', m: '/messages', h: '/', t: '/my-tasks', a: '/consultant/board' };
if (map[e.key]) { e.preventDefault(); window.location.href = map[e.key]; }
return;
}
if (e.key === 'g') {
pendingG = true;
gTimer = setTimeout(() => { pendingG = false; }, 1200);
}
});
// ---- hover cards ----
let cardEl = null;
let hideTimer = null;
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
// allow only safe link schemes (block javascript:/data: etc.)
const safeUrl = (u) => {
const s = String(u ?? '').trim();
if (/^(https?:\/\/|mailto:)/i.test(s)) return s;
if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return '#';
return s ? 'https://' + s : '#';
};
function hideCard() {
if (cardEl) { cardEl.remove(); cardEl = null; }
}
async function showCard(target, userId) {
try {
const res = await api('GET', `/api/v1/users/${userId}/card`);
const c = res.card;
hideCard();
cardEl = document.createElement('div');
cardEl.className = 'hovercard card';
const roles = ['admin', 'consultant', 'developer'].filter((r) => c.roles[r]);
const links = ((c.contact && c.contact.links) || []).filter(Boolean);
cardEl.innerHTML = `
<div class="spread">
${c.avatarFileId ? `<img class="avatar large" src="/files/${c.avatarFileId}" alt="">`
: `<span class="avatar large">${esc(c.name.slice(0, 1).toUpperCase())}</span>`}
<div>
<strong>${esc(c.name)}</strong><br>
${roles.map((r) => `<span class="badge">${r}</span>`).join(' ')}
</div>
</div>
${c.bio ? `<p class="muted">${esc(c.bio.slice(0, 220))}${c.bio.length > 220 ? '…' : ''}</p>` : ''}
${c.contact && c.contact.location ? `<p class="muted">📍 ${esc(c.contact.location)}</p>` : ''}
${c.contact && c.contact.phone ? `<p class="muted">📞 ${esc(c.contact.phone)}</p>` : ''}
${links.length ? `<p class="muted">${links.slice(0, 3).map((l) =>
`<a href="${esc(safeUrl(l))}" target="_blank" rel="noopener">🔗 ${esc(l)}</a>`).join('<br>')}</p>` : ''}
<a class="btn small mt" href="/users/${encodeURIComponent(userId)}">See full profile </a>`;
document.body.appendChild(cardEl);
const rect = target.getBoundingClientRect();
cardEl.style.left = Math.min(rect.left, window.innerWidth - 320) + 'px';
cardEl.style.top = (rect.bottom + window.scrollY + 6) + 'px';
cardEl.addEventListener('mouseenter', () => clearTimeout(hideTimer));
cardEl.addEventListener('mouseleave', () => { hideTimer = setTimeout(hideCard, 200); });
} catch (e) { /* card is best-effort */ }
}
document.addEventListener('mouseover', (e) => {
const target = e.target.closest('[data-user-card]');
if (!target) return;
clearTimeout(hideTimer);
hideTimer = setTimeout(() => showCard(target, target.dataset.userCard), 350);
});
document.addEventListener('mouseout', (e) => {
if (e.target.closest('[data-user-card]')) {
clearTimeout(hideTimer);
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 { subscribe } from '/static/js/ws.js';
import { attachMentions, mentionHTML } from '/static/js/mention.js';
const root = document.getElementById('task-root');
const errorBox = document.getElementById('error');
@@ -57,7 +58,7 @@ async function render() {
const comments = await Promise.all((t.comments || []).map(async (c) => `
<div class="card" style="padding:12px">
<p class="muted" style="margin:0 0 8px">${esc(await userName(c.authorId))} · ${new Date(c.at).toLocaleString()}</p>
<div>${c.body}</div>
<div class="comment-body">${c.body}</div>
</div>`));
const timeline = await Promise.all((t.timeline || []).slice().reverse().map(async (e) => `
@@ -146,6 +147,18 @@ function renderActions() {
catch (e) { toast(e.message, 'err'); }
};
// link to the upstream ticket (same affordance as the atomization board)
if (t.external && t.external.url) {
const a = document.createElement('a');
a.className = 'btn';
a.style.marginRight = '8px';
a.href = t.external.url;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = 'Source ↗';
host.appendChild(a);
}
if (root.dataset.isDeveloper === '1') {
if (t.status === 'published' && !myClaim()) add('Request assignment', post('claim', { note: '' }), true);
if (myClaim() && ['published', 'claim_requested'].includes(t.status)) add('Withdraw request', post('claim/withdraw'));
@@ -182,12 +195,14 @@ function wireHandlers() {
});
document.getElementById('comment-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = document.getElementById('comment-body').value;
const field = document.getElementById('comment-body');
const body = mentionHTML(field.value, field).replace(/\n/g, '<br>');
try {
await api('POST', `/api/v1/tasks/${taskId}/comments`, { body: '<p>' + esc(body).replace(/\n/g, '<br>') + '</p>' });
await api('POST', `/api/v1/tasks/${taskId}/comments`, { body: '<p>' + body + '</p>' });
load();
} catch (err) { toast(err.message, 'err'); }
});
attachMentions(document.getElementById('comment-body'));
const timeForm = document.getElementById('time-form');
if (timeForm) {
timeForm.addEventListener('submit', async (e) => {
+3 -1
View File
@@ -5,7 +5,9 @@
var el = document.documentElement;
var saved = null;
try { saved = localStorage.getItem('theme'); } catch (e) { /* private mode */ }
var THEMES = ['light', 'dark', 'neon', 'terminal', 'blueprint', 'sunset'];
var theme = saved || el.dataset.defaultTheme || 'light';
if (theme !== 'light' && theme !== 'dark') theme = 'light';
if (THEMES.indexOf(theme) === -1) theme = 'light';
el.dataset.theme = theme;
el.dataset.themes = THEMES.join(',');
})();
+12
View File
@@ -31,6 +31,7 @@
<option value="jira">Jira Cloud</option>
<option value="azure_devops">Azure DevOps</option>
<option value="youtrack">YouTrack</option>
<option value="wekan">WeKan</option>
<option value="demo">Demo (offline)</option>
</select>
</div>
@@ -58,6 +59,12 @@
<legend>YouTrack credentials</legend>
<div class="field"><label for="c-yt-token">Permanent token</label><input type="password" id="c-yt-token" autocomplete="off"></div>
</fieldset>
<fieldset id="creds-wekan" class="creds" hidden>
<legend>WeKan credentials</legend>
<p class="hint">Project key above = the WeKan board id; the connector imports cards assigned to each consultant's WeKan username.</p>
<div class="field"><label for="c-wekan-user">Username</label><input type="text" id="c-wekan-user" autocomplete="off"></div>
<div class="field"><label for="c-wekan-pass">Password</label><input type="password" id="c-wekan-pass" autocomplete="off"></div>
</fieldset>
<fieldset id="creds-demo" class="creds" hidden>
<legend>Demo</legend>
<p class="hint">No credentials required — tickets are fabricated locally.</p>
@@ -77,6 +84,11 @@
<select id="c-consultants" multiple size="4"></select>
<p class="hint">Hold Ctrl/Cmd to select multiple. At least one is needed for syncing.</p>
</div>
<div class="field" id="c-identities-wrap" hidden>
<label>Ticketing account mapping</label>
<p class="hint">Username in this project's ticketing system for each assigned consultant. Overrides the consultant's global identity. Leave blank to use their global one.</p>
<div id="c-identities"></div>
</div>
<div class="spread">
<span id="c-test-result" class="hint" aria-live="polite"></span>
<span>
+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}}
+2 -4
View File
@@ -2,7 +2,7 @@
<h1>Bounty Board</h1>
<div class="error-box" id="error" role="alert"></div>
<div class="row mt" id="board-filters">
<div class="toolbar mt" id="board-filters">
<div class="field">
<label for="f-q">Search</label>
<input type="search" id="f-q" placeholder="Search title or description… ( / )">
@@ -22,9 +22,7 @@
<option value="bounty">Highest bounty</option>
</select>
</div>
<div class="field" style="flex:0;align-self:flex-end">
<button class="btn" id="f-save" title="Save current filters as default">Save</button>
</div>
<button class="btn" id="f-save" title="Save current filters as default">Save</button>
</div>
<div class="grid mt" id="board-grid"></div>
+17
View File
@@ -0,0 +1,17 @@
{{define "content"}}
<div class="card">
<h1>Forgot password</h1>
<div class="error-box" id="error" role="alert"></div>
<div class="ok-box" id="ok" role="status"></div>
<form id="forgot-form">
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" autocomplete="email" required>
</div>
<div class="spread">
<button class="btn primary" type="submit">Send reset link</button>
<a href="/login">Back to log in</a>
</div>
</form>
</div>
{{end}}
+9 -4
View File
@@ -3,18 +3,20 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · Bounty Board</title>
<title>{{.Title}} · {{.Brand}}</title>
<link rel="stylesheet" href="/static/css/app.css">
<script src="/static/js/theme.js"></script>
</head>
<body {{if .User}}data-logged-in="1"{{end}}>
<body {{if .User}}data-logged-in="1"{{end}} data-nav="{{if .User}}{{.NavLayout}}{{else}}top{{end}}">
<nav class="topnav">
<a class="brand" href="/">Bounty Board</a>
<a class="brand" href="/">{{.Brand}}</a>
<div class="links">
{{if .User}}
{{if .User.Roles.Developer}}
<a href="/board" {{if eq .Active "board"}}aria-current="page"{{end}}>Bounty Board</a>
<a href="/my-tasks" {{if eq .Active "my-tasks"}}aria-current="page"{{end}}>My Tasks</a>
{{else if .User.Roles.Consultant}}
<a href="/board" {{if eq .Active "board"}}aria-current="page"{{end}}>Bounty Board</a>
{{end}}
{{if .User.Roles.Consultant}}
<a href="/consultant/board" {{if eq .Active "atomization"}}aria-current="page"{{end}}>Atomization</a>
@@ -26,6 +28,7 @@
{{end}}
<a href="/messages" {{if eq .Active "messages"}}aria-current="page"{{end}}>Messages</a>
<a href="/metrics" {{if eq .Active "metrics"}}aria-current="page"{{end}}>Metrics</a>
<a href="/archive" {{if eq .Active "archive"}}aria-current="page"{{end}}>Archive</a>
{{end}}
</div>
<div class="right">
@@ -57,7 +60,9 @@
</main>
<div id="toasts" aria-live="polite"></div>
<script type="module" src="/static/js/nav.js"></script>
{{if .User}}<script type="module" src="/static/js/notifications.js"></script>{{end}}
{{if .User}}<script type="module" src="/static/js/notifications.js"></script>
<script type="module" src="/static/js/shortcuts.js"></script>
<script type="module" src="/static/js/chat-widget.js"></script>{{end}}
{{range .Scripts}}<script type="module" src="{{.}}"></script>
{{end}}
</body>
+10 -7
View File
@@ -1,4 +1,9 @@
{{define "content"}}
<header class="masthead">
<p class="masthead-kicker">Consulting Work Exchange</p>
<p class="masthead-title">◈ {{.Brand}}</p>
<p class="masthead-rule"></p>
</header>
<div class="card">
<h1>Log in</h1>
<div class="error-box" id="error" role="alert">{{.Error}}</div>
@@ -11,15 +16,13 @@
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
</div>
<div class="spread">
<div class="login-actions">
<button class="btn primary" type="submit">Log in</button>
<a href="/register">Create an account</a>
{{if .OIDCEnabled}}<a class="btn" href="/api/v1/auth/oidc/login">Continue with SSO</a>{{end}}
</div>
</form>
{{if .OIDCEnabled}}
<div class="mt">
<a class="btn" href="/api/v1/auth/oidc/login">Continue with SSO</a>
</div>
{{end}}
<p class="muted mt" style="margin-bottom:0">
<a href="/forgot-password">Forgot password?</a> · <a href="/register">Create an account</a>
</p>
</div>
{{end}}
+92
View File
@@ -0,0 +1,92 @@
{{define "content"}}
<div class="error-box" id="error" role="alert"></div>
<div class="chat-layout">
<aside class="chat-sidebar card">
<div class="spread">
<h2>Messages</h2>
<button class="btn small primary" id="conv-new"> New</button>
</div>
<input type="search" id="conv-search" class="mt" placeholder="Search messages…" aria-label="Search messages">
<ul id="conv-list" class="conv-list"></ul>
<ul id="search-results" class="conv-list" hidden></ul>
</aside>
<section class="chat-main card">
<div class="spread">
<h2 id="chat-title">Select a conversation</h2>
<span style="display:flex;align-items:center;gap:10px">
<span class="muted" id="typing-indicator" aria-live="polite"></span>
<button class="btn small" id="chat-members" type="button" hidden>Manage members</button>
</span>
</div>
<div id="chat-messages" class="chat-messages" aria-live="polite"></div>
<form id="chat-form" hidden>
<div id="chat-attachments" class="chat-attachments"></div>
<div class="chat-toolbar" role="toolbar" aria-label="Formatting">
<button class="btn small" type="button" data-cmd="bold" aria-label="Bold"><b>B</b></button>
<button class="btn small" type="button" data-cmd="italic" aria-label="Italic"><i>I</i></button>
<button class="btn small" type="button" data-cmd="underline" aria-label="Underline"><u>U</u></button>
<button class="btn small" type="button" data-cmd="strikeThrough" aria-label="Strikethrough"><s>S</s></button>
<button class="btn small" type="button" data-cmd="insertUnorderedList" aria-label="Bulleted list">• list</button>
<label class="btn small" style="cursor:pointer">📎<input type="file" id="chat-file" multiple hidden></label>
</div>
<div id="chat-composer" class="chat-composer" contenteditable="true" role="textbox"
aria-multiline="true" aria-label="Message" data-placeholder="Write a message… (drag & drop files)"></div>
<div class="spread mt">
<span class="hint">Enter to send · Shift+Enter for newline</span>
<button class="btn primary" type="submit">Send</button>
</div>
</form>
</section>
</div>
<dialog id="newconv-dialog">
<form method="dialog" id="newconv-form" class="stack" style="min-width:420px">
<h2>New conversation</h2>
<div class="field">
<label for="nc-kind">Kind</label>
<select id="nc-kind">
<option value="dm">Direct message</option>
<option value="group">Group</option>
<option value="project">Project</option>
</select>
</div>
<div class="field" id="nc-title-wrap" hidden>
<label for="nc-title">Title</label>
<input type="text" id="nc-title">
</div>
<div class="field" id="nc-customer-wrap" hidden>
<label for="nc-customer">Customer id</label>
<input type="text" id="nc-customer" placeholder="paste customer id">
</div>
<div class="field">
<label for="nc-search">Participants</label>
<input type="search" id="nc-search" placeholder="Search people…">
<div id="nc-results" role="listbox" aria-label="Search results"></div>
<div id="nc-selected" class="mt"></div>
</div>
<div class="spread">
<button class="btn" type="button" id="nc-cancel">Cancel</button>
<button class="btn primary" type="submit">Start conversation</button>
</div>
</form>
</dialog>
<dialog id="members-dialog">
<div class="stack" style="min-width:420px">
<div class="spread">
<h2 style="margin:0">Group members</h2>
<button class="btn small" type="button" id="mm-close">Close</button>
</div>
<ul id="mm-list" class="conv-list"></ul>
<div class="field" id="mm-add-wrap">
<label for="mm-search">Add someone</label>
<input type="search" id="mm-search" placeholder="Search people…">
<div id="mm-results" role="listbox" aria-label="Search results"></div>
</div>
</div>
</dialog>
<dialog id="lightbox" class="lightbox">
<img id="lightbox-img" alt="">
</dialog>
{{end}}
+49
View File
@@ -0,0 +1,49 @@
{{define "content"}}
<h1>Metrics</h1>
<div class="error-box" id="error" role="alert"></div>
<div class="toolbar mt">
<div class="field tight">
<label for="m-from">From</label>
<input type="date" id="m-from">
</div>
<div class="field tight">
<label for="m-to">To</label>
<input type="date" id="m-to">
</div>
<button class="btn" id="m-apply">Apply</button>
<a class="btn" id="m-csv" href="#">Export CSV</a>
</div>
<section id="dev-metrics" hidden>
<div class="grid mt" id="dev-cards"></div>
<div class="card mt"><h2>Earnings over time (weekly)</h2><div id="dev-weekly"></div></div>
<div class="card mt"><h2>Per customer</h2><div id="dev-customers"></div></div>
</section>
<section id="cons-metrics" hidden>
<div class="toolbar mt">
<div class="field tight">
<label for="m-customer">Customer</label>
<select id="m-customer"><option value="">All</option></select>
</div>
</div>
<div class="grid mt" id="cons-cards"></div>
<div class="card mt"><h2>Awarded over time (weekly)</h2><div id="cons-weekly"></div></div>
<div class="row mt">
<div class="card"><h2>Per developer</h2><div id="cons-devs"></div></div>
<div class="card"><h2>Per customer</h2><div id="cons-customers"></div></div>
</div>
</section>
<div class="card mt">
<div class="spread">
<h2>Leaderboard — top developers</h2>
<label class="muted"><input type="checkbox" id="lb-optout"> Hide me from the leaderboard</label>
</div>
<table class="list" id="leaderboard">
<thead><tr><th>#</th><th>Developer</th><th>Tasks</th><th>Bounty</th></tr></thead>
<tbody></tbody>
</table>
</div>
{{end}}
+13 -2
View File
@@ -57,8 +57,19 @@
<div class="field">
<label for="p-theme">Theme</label>
<select id="p-theme">
<option value="light" {{if eq .User.Settings.Theme "light"}}selected{{end}}>Light (beige)</option>
<option value="dark" {{if eq .User.Settings.Theme "dark"}}selected{{end}}>Dark</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 (Y2K ink)</option>
<option value="neon" {{if eq .User.Settings.Theme "neon"}}selected{{end}}>Neon (cyber gradient)</option>
<option value="terminal" {{if eq .User.Settings.Theme "terminal"}}selected{{end}}>Terminal (green CRT)</option>
<option value="blueprint" {{if eq .User.Settings.Theme "blueprint"}}selected{{end}}>Blueprint (graph paper)</option>
<option value="sunset" {{if eq .User.Settings.Theme "sunset"}}selected{{end}}>Sunset (vaporwave)</option>
</select>
</div>
<div class="field">
<label for="p-nav">Navigation</label>
<select id="p-nav">
<option value="side" {{if ne .User.Settings.NavLayout "top"}}selected{{end}}>Sidebar (left)</option>
<option value="top" {{if eq .User.Settings.NavLayout "top"}}selected{{end}}>Top bar</option>
</select>
</div>
+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}}
+5
View File
@@ -1,4 +1,9 @@
{{define "content"}}
<header class="masthead">
<p class="masthead-kicker">Consulting Work Exchange</p>
<p class="masthead-title">◈ {{.Brand}}</p>
<p class="masthead-rule"></p>
</header>
<div class="card">
<h1>Create account</h1>
<p class="hint">Self-registration creates a developer account. Consultant and admin roles are granted by an administrator.</p>
+17
View File
@@ -0,0 +1,17 @@
{{define "content"}}
<div class="card">
<h1>Set a new password</h1>
<div class="error-box" id="error" role="alert"></div>
<form id="reset-form">
<div class="field">
<label for="new">New password</label>
<input type="password" id="new" autocomplete="new-password" minlength="8" required>
</div>
<div class="field">
<label for="confirm">Confirm new password</label>
<input type="password" id="confirm" autocomplete="new-password" minlength="8" required>
</div>
<button class="btn primary" type="submit">Set password</button>
</form>
</div>
{{end}}