Compare commits

...

10 Commits

Author SHA1 Message Date
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
72 changed files with 6160 additions and 186 deletions
+3
View File
@@ -1,6 +1,9 @@
# --- core --- # --- core ---
# Set APP_BASE_URL to the public https URL when behind the reverse proxy. # Set APP_BASE_URL to the public https URL when behind the reverse proxy.
APP_BASE_URL=http://localhost:8787 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. # Comma-separated CIDRs of trusted reverse proxies, e.g. 172.16.0.0/12,10.0.0.0/8.
# Empty = trust no proxy headers. # Empty = trust no proxy headers.
TRUSTED_PROXY_CIDRS= TRUSTED_PROXY_CIDRS=
+62
View File
@@ -104,3 +104,65 @@ Spec-silent choices, recorded as required by the build instructions.
/change-password. /change-password.
- **Pages for later phases render an "under construction" placeholder** so - **Pages for later phases render an "under construction" placeholder** so
role-based navigation is complete and clickable now. 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).
+17 -3
View File
@@ -4,6 +4,7 @@ GO ?= go
build: build:
$(GO) build ./... $(GO) build ./...
cd services/atomizer && $(GO) build ./...
run: run:
$(GO) run ./cmd/app $(GO) run ./cmd/app
@@ -16,19 +17,32 @@ test:
test-integration: test-integration:
$(GO) test -tags=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: lint:
@unformatted=$$(gofmt -l .); if [ -n "$$unformatted" ]; then \ @unformatted=$$(gofmt -l .); if [ -n "$$unformatted" ]; then \
echo "gofmt needed on:"; echo "$$unformatted"; exit 1; fi echo "gofmt needed on:"; echo "$$unformatted"; exit 1; fi
$(GO) vet ./... $(GO) vet ./...
# Requires Mongo published on loopback (test overlay) or MONGO_SEED_URI.
seed: seed:
@echo "seed script lands in phase 12 (scripts/seed.go)"; exit 1 $(GO) run ./scripts
smoke: smoke:
./scripts/smoke.sh ./scripts/smoke.sh
# mongodump/mongorestore through the mongo container into ./backups (§11.23)
backup: 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: 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 6 (sync workers): per-customer pollers with reconcile loop (start/stop/interval changes), §5.3 idempotent upsert keyed (system,key,customerId) w/ content-hash change detection, upstream edits refresh only while imported (timeline+notification after), attachment caching to GridFS, orphan flagging (never deletes), admin sync-now trigger + worker statuses, default budget prefill — demo-type integration tests green.
- Phase 7 (atomization): circuit breaker (3 fails→open, 60s half-open probe) + retrying bearer JSON client shared by both external services, atomizer client w/ §5.1 coefficient normalization (±0.001 ok, ≤0.05 renormalized, else 502-class), persisted jobs queue (panic-safe, backoff ×3, stale requeue, restart-safe), subdivide/extend endpoints (202 async, re-run replaces unpublished children after confirm), task editing w/ bounty recompute + optional budget cascade, publish single/bulk, WS hub (origin check, 30s heartbeats) + live board, consultant atomization board UI (tree, sliders, sum indicator, modals, shimmer, health gating) — unit + integration green. Fixed tasks unique index: sparse→partial (sparse compound matched every task via customerId). - Phase 7 (atomization): circuit breaker (3 fails→open, 60s half-open probe) + retrying bearer JSON client shared by both external services, atomizer client w/ §5.1 coefficient normalization (±0.001 ok, ≤0.05 renormalized, else 502-class), persisted jobs queue (panic-safe, backoff ×3, stale requeue, restart-safe), subdivide/extend endpoints (202 async, re-run replaces unpublished children after confirm), task editing w/ bounty recompute + optional budget cascade, publish single/bulk, WS hub (origin check, 30s heartbeats) + live board, consultant atomization board UI (tree, sliders, sum indicator, modals, shimmer, health gating) — unit + integration green. Fixed tasks unique index: sparse→partial (sparse compound matched every task via customerId).
- Phase 8 (bounty board + lifecycle): developer board (pool-scoped, filters/search/sort, stale age badges, hide-competing-claims setting, saved filters), claim/withdraw/decline/approve, assign-to-AI via §5.2 client + HMAC-verified idempotent callback w/ artifact ingestion into GridFS, start/submit/abandon/unassign, comments (sanitized, @mentions→notifications), time log, review queue + per-AC checklist on timeline, immutable bountyAwards on approve (humans only), notifications center + bell + WS toasts, my-tasks kanban, task detail page, pool management UI, server-side HTML sanitizer w/ XSS vector tests — full lifecycle integration tests green. - 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}
+3 -1
View File
@@ -86,6 +86,8 @@ func run() error {
}) })
srv.SetWSHandler(hub.Handle) srv.SetWSHandler(hub.Handle)
srv.SetPublishFn(hub.Broadcast) srv.SetPublishFn(hub.Broadcast)
srv.SetSendTo(hub.SendTo)
hub.SetInbound(srv.HandleInboundWS)
// Atomizer client honoring the admin base-URL override per call. // Atomizer client honoring the admin base-URL override per call.
atomClient := atomize.New(func() string { atomClient := atomize.New(func() string {
@@ -107,7 +109,7 @@ func run() error {
// Background job queue + atomization handlers. // Background job queue + atomization handlers.
runner := jobs.NewRunner(st, log, reg, 4) runner := jobs.NewRunner(st, log, reg, 4)
atomSvc := atomize.NewService(st, atomClient, log, cfg.AppBaseURL, atomSvc := atomize.NewService(st, atomClient, log, cfg.AppInternalURL,
[]byte(cfg.SessionSecret), srv.Publish) []byte(cfg.SessionSecret), srv.Publish)
runner.Register(atomize.JobKindSubdivide, atomSvc.HandleSubdivide) runner.Register(atomize.JobKindSubdivide, atomSvc.HandleSubdivide)
runner.Register(atomize.JobKindExtend, atomSvc.HandleExtend) runner.Register(atomize.JobKindExtend, atomSvc.HandleExtend)
+57 -1
View File
@@ -27,8 +27,16 @@ services:
env_file: .env env_file: .env
environment: environment:
MONGO_URI: mongodb://mongo:27017 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: ports:
- "127.0.0.1:${APP_PORT:-8787}:8787" - "${APP_PORT:-8787}:8787"
# - "127.0.0.1:${APP_PORT:-8787}:8787"
depends_on: depends_on:
mongo: mongo:
condition: service_healthy condition: service_healthy
@@ -39,5 +47,53 @@ services:
retries: 3 retries: 3
start_period: 10s start_period: 10s
# --- 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
- work-data:/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
volumes: volumes:
mongo-data: mongo-data:
work-data:
+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
}
+6 -1
View File
@@ -36,7 +36,11 @@ func (o OIDC) Enabled() bool {
} }
type Config struct { 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 TrustedProxyCIDRs []netip.Prefix
AppPort int AppPort int
MongoURI string MongoURI string
@@ -132,6 +136,7 @@ func Load() (*Config, error) {
CookieSecureMode: getenv("COOKIE_SECURE", "auto"), CookieSecureMode: getenv("COOKIE_SECURE", "auto"),
AtomizeMaxConcurrency: atoi("ATOMIZE_MAX_CONCURRENCY", "3"), 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.AtomizerTimeout = time.Duration(atoi("ATOMIZER_TIMEOUT_SEC", "120")) * time.Second
c.WorkPerformerHTTPTimeout = time.Duration(atoi("WORK_PERFORMER_HTTP_TIMEOUT_SEC", "30")) * time.Second c.WorkPerformerHTTPTimeout = time.Duration(atoi("WORK_PERFORMER_HTTP_TIMEOUT_SEC", "30")) * time.Second
+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)
}
}
+2 -1
View File
@@ -8,6 +8,7 @@ const (
TicketingJira TicketingType = "jira" TicketingJira TicketingType = "jira"
TicketingAzure TicketingType = "azure_devops" TicketingAzure TicketingType = "azure_devops"
TicketingYouTrack TicketingType = "youtrack" TicketingYouTrack TicketingType = "youtrack"
TicketingWekan TicketingType = "wekan"
// TicketingDemo fabricates tickets locally so the whole flow works // TicketingDemo fabricates tickets locally so the whole flow works
// offline (§11.11). // offline (§11.11).
TicketingDemo TicketingType = "demo" TicketingDemo TicketingType = "demo"
@@ -15,7 +16,7 @@ const (
func (t TicketingType) Valid() bool { func (t TicketingType) Valid() bool {
switch t { switch t {
case TicketingJira, TicketingAzure, TicketingYouTrack, TicketingDemo: case TicketingJira, TicketingAzure, TicketingYouTrack, TicketingWekan, TicketingDemo:
return true return true
} }
return false return false
+3 -2
View File
@@ -53,8 +53,9 @@ type NotificationPrefs struct {
} }
type UserSettings struct { type UserSettings struct {
Theme string `bson:"theme" json:"theme"` Theme string `bson:"theme" json:"theme"`
Notifications NotificationPrefs `bson:"notifications" json:"notifications"` Notifications NotificationPrefs `bson:"notifications" json:"notifications"`
LeaderboardOptOut bool `bson:"leaderboardOptOut" json:"leaderboardOptOut"`
} }
type User struct { type User struct {
+1 -1
View File
@@ -119,7 +119,7 @@ func (s *Server) handleAdminCreateCustomer(w http.ResponseWriter, r *http.Reques
return return
} }
if req.Type == nil || !req.Type.Valid() { 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 return
} }
settings, err := s.store.GetSettings(r.Context()) settings, err := s.store.GetSettings(r.Context())
+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.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/login", s.handleOIDCLogin)
mux.HandleFunc("GET /api/v1/auth/oidc/callback", s.handleOIDCCallback) 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. // decodeJSON reads a bounded JSON body into dst, rejecting unknown fields.
+58 -38
View File
@@ -33,30 +33,56 @@ func (s *Server) routesBoard(mux *http.ServeMux) {
mux.Handle("GET /api/v1/users/{id}/card", s.requireAuth(http.HandlerFunc(s.handleUserCard))) mux.Handle("GET /api/v1/users/{id}/card", s.requireAuth(http.HandlerFunc(s.handleUserCard)))
} }
// boardConsultants resolves which consultants' tasks the developer sees: // boardCustomers resolves the developer's visibility scope: every customer
// the consultants who selected them into a pool (§3). // that has at least one consultant who selected this developer into a pool.
func (s *Server) boardConsultants(r *http.Request) ([]string, error) { // Visibility is customer-based, not task-owner-based — §4.4: all consultants
return s.store.PoolConsultantIDs(r.Context(), CurrentUser(r.Context()).ID) // assigned to a customer manage its tasks, so a task published by any of
// them belongs to the same board.
func (s *Server) boardCustomers(r *http.Request) ([]domain.Customer, error) {
consultants, err := s.store.PoolConsultantIDs(r.Context(), CurrentUser(r.Context()).ID)
if err != nil {
return nil, err
}
seen := map[string]bool{}
out := []domain.Customer{}
for _, cid := range consultants {
customers, err := s.store.ListCustomers(r.Context(), cid, false)
if err != nil {
return nil, err
}
for _, c := range customers {
if !seen[c.ID] {
seen[c.ID] = true
out = append(out, c)
}
}
}
return out, nil
} }
func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request) { func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request) {
consultants, err := s.boardConsultants(r) customers, err := s.boardCustomers(r)
if err != nil { if err != nil {
s.internalError(w, r, "pool lookup", err) s.internalError(w, r, "board scope", err)
return return
} }
q := r.URL.Query() 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{} tasks := []domain.Task{}
if len(consultants) > 0 { if len(customerIDs) > 0 {
minBounty, _ := strconv.ParseFloat(q.Get("minBounty"), 64) minBounty, _ := strconv.ParseFloat(q.Get("minBounty"), 64)
filter := store.TaskFilter{ filter := store.TaskFilter{
ConsultantIDs: consultants, CustomerIDs: customerIDs,
Statuses: []domain.TaskStatus{domain.StatusPublished, domain.StatusClaimRequested}, Statuses: []domain.TaskStatus{domain.StatusPublished, domain.StatusClaimRequested},
Search: strings.TrimSpace(q.Get("q")), Search: strings.TrimSpace(q.Get("q")),
MinBounty: minBounty, MinBounty: minBounty,
CustomerID: q.Get("customerId"), Sort: q.Get("sort"), // newest (default) | bounty
Sort: q.Get("sort"), // newest (default) | bounty Limit: 200,
Limit: 200,
} }
if tasks, err = s.store.ListTasks(r.Context(), filter); err != nil { if tasks, err = s.store.ListTasks(r.Context(), filter); err != nil {
s.internalError(w, r, "list board", err) s.internalError(w, r, "list board", err)
@@ -78,18 +104,12 @@ func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request) {
tasks[i].ClaimRequests = mine tasks[i].ClaimRequests = mine
} }
// customers for the filter dropdown // customers for the filter dropdown (full visibility scope)
customerIDs := map[string]bool{} customersOut := make([]map[string]any, len(customers))
for _, t := range tasks { for i, c := range customers {
customerIDs[t.CustomerID] = true customersOut[i] = map[string]any{"id": c.ID, "name": c.Name}
} }
customers := []map[string]any{} writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customers": customersOut})
for id := range customerIDs {
if c, err := s.store.CustomerByID(r.Context(), id); err == nil {
customers = append(customers, map[string]any{"id": c.ID, "name": c.Name})
}
}
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customers": customers})
} }
func (s *Server) handleMyTasks(w http.ResponseWriter, r *http.Request) { func (s *Server) handleMyTasks(w http.ResponseWriter, r *http.Request) {
@@ -110,8 +130,9 @@ func (s *Server) handleMyTasks(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks}) writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks})
} }
// developerTask loads a task ensuring the developer can see it via a pool // developerTask loads a task ensuring the developer can see it: the task's
// relationship with its consultant. // 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) { func (s *Server) developerTask(w http.ResponseWriter, r *http.Request, id string) (*domain.Task, bool) {
t, err := s.store.TaskByID(r.Context(), id) t, err := s.store.TaskByID(r.Context(), id)
if err != nil { if err != nil {
@@ -122,20 +143,19 @@ func (s *Server) developerTask(w http.ResponseWriter, r *http.Request, id string
} }
return nil, false 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) { if t.IsAssignedTo(CurrentUser(r.Context()).ID) {
return t, true 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") writeError(w, http.StatusForbidden, "forbidden", "this task is not on your board")
return nil, false return nil, false
} }
+2 -2
View File
@@ -140,7 +140,7 @@ func (s *Server) handleAssignAI(w http.ResponseWriter, r *http.Request) {
for _, a := range t.Attachments { for _, a := range t.Attachments {
url := a.URL url := a.URL
if a.FileID != "" { 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)) time.Now().Add(files.DefaultTokenTTL))
} }
atts = append(atts, workperform.Attachment{Name: a.Name, URL: url, MimeType: a.MimeType}) 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, Attachments: atts,
Links: t.Links, Links: t.Links,
Context: req.Context, Context: req.Context,
CallbackURL: s.cfg.AppBaseURL + "/api/v1/internal/work-results", CallbackURL: s.cfg.AppInternalURL + "/api/v1/internal/work-results",
}) })
if err != nil { if err != nil {
s.log.Error("submit wp job", "task", t.ID, "err", err) 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) { func TestDeveloperOutsidePoolSeesNothing(t *testing.T) {
l := newLifecycleStack(t, nil) l := newLifecycleStack(t, nil)
base := l.ts.URL + "/api/v1/tasks/" + l.task.ID base := l.ts.URL + "/api/v1/tasks/" + l.task.ID
+360
View File
@@ -0,0 +1,360 @@
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("POST /api/v1/files", s.authed(s.handleUploadFile))
mux.Handle("GET /api/v1/users", s.requireAuth(http.HandlerFunc(s.handleSearchUsers)))
}
// 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,
"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),
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)
}
plain := chat.SanitizePlain(body)
for _, pid := range conv.ParticipantIDs {
if pid == me.ID {
continue
}
if u, err := s.store.UserByID(r.Context(), pid); err == nil {
first := strings.ToLower(strings.SplitN(u.Name, " ", 2)[0])
lower := strings.ToLower(plain)
if strings.Contains(lower, "@"+strings.ToLower(u.Email)) || strings.Contains(lower, "@"+first) {
s.notifyUser(r.Context(), pid, "mention",
me.Name+" mentioned you in a chat", truncateStr(plain, 120), "/messages?c="+conv.ID)
}
}
}
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})
}
// 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})
}
// 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 // securityHeaders applies the §12 hardening headers. CSP allows no inline
// scripts — all JS ships as external modules. // scripts — all JS ships as external modules.
func (s *Server) securityHeaders(next http.Handler) http.Handler { 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'; " + "img-src 'self' data:; connect-src 'self'; font-src 'self'; " +
"frame-ancestors 'none'; base-uri 'self'; form-action 'self'" "frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+25 -5
View File
@@ -38,8 +38,9 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
Contact *domain.Contact `json:"contact"` Contact *domain.Contact `json:"contact"`
Extra *map[string]any `json:"extra"` Extra *map[string]any `json:"extra"`
Settings *struct { Settings *struct {
Theme *string `json:"theme"` Theme *string `json:"theme"`
Notifications *domain.NotificationPrefs `json:"notifications"` Notifications *domain.NotificationPrefs `json:"notifications"`
LeaderboardOptOut *bool `json:"leaderboardOptOut"`
} `json:"settings"` } `json:"settings"`
} }
if !decodeJSON(w, r, &req) { if !decodeJSON(w, r, &req) {
@@ -79,6 +80,9 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
if req.Settings.Notifications != nil { if req.Settings.Notifications != nil {
set["settings.notifications"] = *req.Settings.Notifications set["settings.notifications"] = *req.Settings.Notifications
} }
if req.Settings.LeaderboardOptOut != nil {
set["settings.leaderboardOptOut"] = *req.Settings.LeaderboardOptOut
}
} }
if len(set) == 0 { if len(set) == 0 {
writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields to update") writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields to update")
@@ -193,9 +197,25 @@ func (s *Server) handleGetFile(w http.ResponseWriter, r *http.Request) {
switch meta.Scope { switch meta.Scope {
case files.ScopeAvatar: case files.ScopeAvatar:
allowed = true // avatars are visible to all logged-in users allowed = true // avatars are visible to all logged-in users
default: case files.ScopeChat:
allowed = meta.OwnerID == user.ID || user.Roles.Admin || // uploader always; otherwise participant of the conversation the
user.Roles.Consultant // scoped tighter as chat/task features land // 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 { if !allowed {
writeError(w, http.StatusForbidden, "forbidden", "no access to this file") writeError(w, http.StatusForbidden, "forbidden", "no access to this file")
+11
View File
@@ -28,6 +28,7 @@ type Server struct {
files *files.Store files *files.Store
templates map[string]*template.Template templates map[string]*template.Template
oidc *auth.OIDCClient oidc *auth.OIDCClient
mailer *auth.Mailer
loginLimiter *auth.RateLimiter loginLimiter *auth.RateLimiter
checks []ReadinessCheck checks []ReadinessCheck
startedAt time.Time startedAt time.Time
@@ -42,9 +43,15 @@ type Server struct {
publishFn func(channel, event string, payload any) publishFn func(channel, event string, payload any)
enqueueFn func(ctx context.Context, kind string, payload any) (string, error) enqueueFn func(ctx context.Context, kind string, payload any) (string, error)
wsHandler func(w http.ResponseWriter, r *http.Request, userID string) wsHandler func(w http.ResponseWriter, r *http.Request, userID string)
sendTo func(userIDs []string, channel, event string, payload any)
performerClient *workperform.Client 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). // SetPerformerClient wires the §5.2 client (also feeds the status panel).
func (s *Server) SetPerformerClient(c *workperform.Client) { func (s *Server) SetPerformerClient(c *workperform.Client) {
s.performerClient = c s.performerClient = c
@@ -66,6 +73,7 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.
files: fs, files: fs,
templates: templates, templates: templates,
oidc: auth.NewOIDCClient(cfg, log), oidc: auth.NewOIDCClient(cfg, log),
mailer: auth.NewMailer(cfg.SMTP),
loginLimiter: auth.NewRateLimiter(10, 15*time.Minute), loginLimiter: auth.NewRateLimiter(10, 15*time.Minute),
startedAt: time.Now(), startedAt: time.Now(),
} }
@@ -81,6 +89,9 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.
s.routesBoard(mux) s.routesBoard(mux)
s.routesConsultant(mux) s.routesConsultant(mux)
s.routesWorkResults(mux) s.routesWorkResults(mux)
s.routesMessages(mux)
s.routesMetrics(mux)
s.routesDocs(mux)
mux.HandleFunc("GET /ws", s.handleWS) mux.HandleFunc("GET /ws", s.handleWS)
s.routesWeb(mux) s.routesWeb(mux)
+39
View File
@@ -22,6 +22,7 @@ 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/{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/publish", s.authedRole("consultant", s.handlePublishBulk))
mux.Handle("POST /api/v1/tasks/{id}/archive", s.authed(s.handleArchiveTask)) mux.Handle("POST /api/v1/tasks/{id}/archive", s.authed(s.handleArchiveTask))
mux.Handle("POST /api/v1/tasks/archive", s.authedRole("consultant", s.handleArchiveBulk))
mux.Handle("GET /api/v1/tasks/{id}", s.requireAuth(http.HandlerFunc(s.handleTaskDetail))) mux.Handle("GET /api/v1/tasks/{id}", s.requireAuth(http.HandlerFunc(s.handleTaskDetail)))
mux.Handle("GET /api/v1/service-health", s.requireAuth(http.HandlerFunc(s.handleServiceHealth))) mux.Handle("GET /api/v1/service-health", s.requireAuth(http.HandlerFunc(s.handleServiceHealth)))
} }
@@ -405,6 +406,44 @@ func (s *Server) handleArchiveTask(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) 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; // handleTaskDetail is role-scoped: admins and customer consultants always;
// developers when they are the assignee, have a claim, or the task is on the // developers when they are the assignee, have a claim, or the task is on the
// public board (published/claim_requested). // public board (published/claim_requested).
+63 -16
View File
@@ -1,13 +1,17 @@
package httpx package httpx
import ( import (
"crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"html"
"html/template" "html/template"
"io/fs" "io/fs"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"bountyboard/api"
"bountyboard/internal/domain" "bountyboard/internal/domain"
"bountyboard/web" "bountyboard/web"
) )
@@ -124,8 +128,19 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
if err != nil { if err != nil {
panic(err) // embed layout is fixed at compile time panic(err) // embed layout is fixed at compile time
} }
mux.Handle("GET /static/", http.StripPrefix("/static/", // no-cache + a build-wide strong ETag: browsers revalidate on every
cacheControl(http.FileServerFS(staticFS), "public, max-age=3600"))) // 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) { 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}) s.render(w, r, "home.html", &pageData{Title: "Home", User: u})
@@ -155,6 +170,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) { 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{ s.render(w, r, "change-password.html", &pageData{
Title: "Change password", User: u, Narrow: true, Title: "Change password", User: u, Narrow: true,
@@ -219,16 +245,26 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
}) })
})) }))
// Placeholders for areas built in later phases; replaced as they land. rolePage("/messages", "messages.html", "Messages", "messages", "/static/js/messages.js", nil)
placeholders := map[string]string{ rolePage("/metrics", "metrics.html", "Metrics", "metrics", "/static/js/metrics.js", nil)
"/messages": "Messages", }
"/metrics": "Metrics",
} // routesDocs serves the OpenAPI document and a minimal viewer (§6).
for path, title := range placeholders { func (s *Server) routesDocs(mux *http.ServeMux) {
mux.HandleFunc("GET "+path, s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) { mux.HandleFunc("GET /api/openapi.yaml", func(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "placeholder.html", &pageData{Title: title, User: u}) 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 { func loginErrorMessage(code string) string {
@@ -250,9 +286,20 @@ func loginErrorMessage(code string) string {
} }
} }
func cacheControl(next http.Handler, value string) http.Handler { // staticETag hashes every embedded static asset into one build-wide ETag.
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { func staticETag(fsys fs.FS) string {
w.Header().Set("Cache-Control", value) h := sha256.New()
next.ServeHTTP(w, r) 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) { func TestStaticAssetsServed(t *testing.T) {
s := newTestServer(t) s := newTestServer(t)
tests := map[string]string{ 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/theme.js": "data-default-theme",
"/static/js/api.js": "X-CSRF-Token", "/static/js/api.js": "X-CSRF-Token",
"/static/js/login.js": "auth/login", "/static/js/login.js": "auth/login",
@@ -81,10 +81,11 @@ func TestThemeTokensPresent(t *testing.T) {
s := newTestServer(t) s := newTestServer(t)
rec := get(t, s.Handler(), "/static/css/app.css") rec := get(t, s.Handler(), "/static/css/app.css")
css := rec.Body.String() 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{ for _, tok := range []string{
"--bg:#f3ead9", "--accent:#8a5a2b", // light "--bg:#ffffff", "--accent:#7a4a14", // light: white paper, brown ink
"--bg:#191714", "--accent:#caa15e", // dark "--bg:#171209", "--accent:#d9a548", // dark
"--radius:2px", "--radius:2px",
} { } {
if !strings.Contains(css, tok) { if !strings.Contains(css, tok) {
+228
View File
@@ -0,0 +1,228 @@
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"`
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
}
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: "participantIds", Value: 1}, {Key: "lastMessageAt", Value: -1}}},
{Keys: bson.D{{Key: "kind", Value: 1}, {Key: "customerId", 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": { "notifications": {
{Keys: bson.D{{Key: "userId", Value: 1}, {Key: "readAt", Value: 1}, {Key: "createdAt", Value: -1}}}, {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: "expiresAt", Value: 1}}, Options: options.Index().SetExpireAfterSeconds(0)},
{Keys: bson.D{{Key: "userId", Value: 1}}}, {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": { "auditLog": {
{Keys: bson.D{{Key: "at", Value: -1}}}, {Keys: bson.D{{Key: "at", Value: -1}}},
{Keys: bson.D{{Key: "entityId", Value: 1}, {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 nil, fmt.Errorf("youtrack requires permanentToken")
} }
return &youtrackConnector{baseURL: baseURL, projectKey: projectKey, token: creds["permanentToken"]}, nil 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: case domain.TicketingDemo:
return &demoConnector{projectKey: projectKey}, nil return &demoConnector{projectKey: projectKey}, nil
default: default:
+19 -5
View File
@@ -10,6 +10,8 @@ import (
"sync" "sync"
"time" "time"
"go.mongodb.org/mongo-driver/v2/bson"
"bountyboard/internal/domain" "bountyboard/internal/domain"
"bountyboard/internal/files" "bountyboard/internal/files"
"bountyboard/internal/metrics" "bountyboard/internal/metrics"
@@ -287,12 +289,24 @@ func ticketingIdentity(u *domain.User, t domain.TicketingType) string {
if t == domain.TicketingDemo { if t == domain.TicketingDemo {
return u.Email // demo accepts any identity return u.Email // demo accepts any identity
} }
ids, _ := u.Extra["ticketingIdentities"].(map[string]any) // The driver decodes the nested document as bson.D when the field type
if ids == nil { // is `any`; JSON-built values arrive as map[string]any. Handle both.
return "" 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 ""
return v
} }
func contentHash(t Ticket) string { 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) { func TestSyncSkipsConsultantWithoutIdentity(t *testing.T) {
_, st, customer, _ := newSyncStack(t) _, st, customer, _ := newSyncStack(t)
ctx := context.Background() 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 #!/usr/bin/env bash
# Curl-based smoke test against a running stack (docker compose up -d). # Curl-based smoke test (§13): register → login → healthz → board against a
# Grows with the system; later phases add register→login→board coverage. # running stack (docker compose up -d).
set -euo pipefail set -euo pipefail
BASE_URL="${BASE_URL:-http://localhost:8787}" BASE_URL="${BASE_URL:-http://localhost:8787}"
JAR=$(mktemp)
trap 'rm -f "$JAR" /tmp/smoke-*.json' EXIT
fail() { echo "SMOKE FAIL: $*" >&2; exit 1; } fail() { echo "SMOKE FAIL: $*" >&2; exit 1; }
echo "smoke: $BASE_URL" 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") \ code=$(curl -fsS -o /tmp/smoke-healthz.json -w '%{http_code}' "$BASE_URL/healthz") \
|| fail "healthz unreachable" || fail "healthz unreachable"
[ "$code" = "200" ] || fail "healthz returned $code" [ "$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" 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") \ code=$(curl -sS -o /tmp/smoke-readyz.json -w '%{http_code}' "$BASE_URL/readyz") \
|| fail "readyz unreachable" || fail "readyz unreachable"
[ "$code" = "200" ] || fail "readyz returned $code: $(cat /tmp/smoke-readyz.json)" [ "$code" = "200" ] || fail "readyz returned $code: $(cat /tmp/smoke-readyz.json)"
echo " readyz ok" echo " readyz ok"
# metricsz exposes counters
curl -fsS "$BASE_URL/metricsz" | grep -q '"counters"' || fail "metricsz body unexpected" curl -fsS "$BASE_URL/metricsz" | grep -q '"counters"' || fail "metricsz body unexpected"
echo " metricsz ok" 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" 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)));
+565 -88
View File
@@ -1,136 +1,393 @@
/* Bounty Board — hand-written CSS, no framework (§2.2). /* Bounty Board — "The Ledger".
Theming via CSS custom properties on <html data-theme>. */ A vintage bounty-poster / financial-ledger aesthetic on warm paper:
engraved display serif, characterful grotesk UI, tabular mono numerals,
hairline double-rules, stamped badges, paper grain. Hand-written CSS, no
framework (§2.2). Design tokens below are the exact §10 values. */
/* ---- self-hosted variable fonts (no build step, CSP: font-src 'self') ---- */
@font-face {
font-family: "Fraunces";
src: url("/static/fonts/Fraunces-var.woff2") format("woff2");
font-weight: 100 900;
font-display: swap;
}
@font-face {
font-family: "Schibsted Grotesk";
src: url("/static/fonts/SchibstedGrotesk-var.woff2") format("woff2");
font-weight: 400 900;
font-display: swap;
}
@font-face {
font-family: "Spline Sans Mono";
src: url("/static/fonts/SplineSansMono-var.woff2") format("woff2");
font-weight: 300 700;
font-display: swap;
}
/* ---- design tokens — "Y2K dither" rework (user-requested deviation from
the §10 beige): paper-white ground, brown ink everywhere, ordered-dither
halftone textures, hard offset shadows. Structure and radius unchanged. */
:root[data-theme=light] { :root[data-theme=light] {
--bg:#f3ead9; --surface:#faf5ea; --surface2:#efe5d0; --border:#d8cbb0; --bg:#ffffff; --surface:#fdfbf6; --surface2:#f2e9d8; --border:#a88452;
--text:#2b2620; --muted:#6f6353; --accent:#8a5a2b; --accent-contrast:#fff; --text:#33230e; --muted:#7c6647; --accent:#7a4a14; --accent-contrast:#fff;
--ok:#3c6e47; --warn:#a06a1f; --err:#9c3a2e; --radius:2px; --ok:#2e6b3c; --warn:#955d0e; --err:#9c3a2e; --radius:2px;
} }
:root[data-theme=dark] { :root[data-theme=dark] {
--bg:#191714; --surface:#221f1b; --surface2:#2b2722; --border:#3a342c; --bg:#171209; --surface:#221a0e; --surface2:#2e2414; --border:#7c5f33;
--text:#ece5d8; --muted:#a59a87; --accent:#caa15e; --accent-contrast:#1a160f; --text:#f3e8d2; --muted:#b59c74; --accent:#d9a548; --accent-contrast:#241606;
--ok:#7fb78a; --warn:#d9a44a; --err:#d97b6c; --radius:2px; --ok:#7fb78a; --warn:#d9a44a; --err:#d97b6c; --radius:2px;
} }
/* derived, theme-aware atmosphere */
:root {
--font-display: "Fraunces", Georgia, serif;
--font-body: "Schibsted Grotesk", system-ui, sans-serif;
--font-mono: "Spline Sans Mono", ui-monospace, monospace;
--hairline: 1px solid var(--border);
}
:root[data-theme=light] {
/* Y2K hard offset shadow, no blur */
--ink-shadow: 3px 3px 0 color-mix(in srgb, var(--border) 45%, transparent);
--ink-shadow-lift: 5px 5px 0 color-mix(in srgb, var(--border) 55%, transparent);
/* ordered-dither tile: sparse diagonal brown pixels on transparent */
--dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' x='0' y='0' fill='%237a4a14'/%3E%3Crect width='1' height='1' x='2' y='2' fill='%237a4a14'/%3E%3C/svg%3E");
--dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2' height='2'%3E%3Crect width='1' height='1' x='0' y='0' fill='%237a4a14'/%3E%3C/svg%3E");
}
:root[data-theme=dark] {
--ink-shadow: 3px 3px 0 rgba(0, 0, 0, 0.55);
--ink-shadow-lift: 5px 5px 0 rgba(0, 0, 0, 0.65);
--dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' x='0' y='0' fill='%23d9a548'/%3E%3Crect width='1' height='1' x='2' y='2' fill='%23d9a548'/%3E%3C/svg%3E");
--dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2' height='2'%3E%3Crect width='1' height='1' x='0' y='0' fill='%23d9a548'/%3E%3C/svg%3E");
}
/* ---- base ---- */ /* ---- base ---- */
* { box-sizing: border-box; } * { box-sizing: border-box; }
/* author display rules (flex etc.) must never defeat the hidden attribute */
[hidden] { display: none !important; }
html { font-size: 16px; } html { font-size: 16px; }
body { body {
margin: 0; margin: 0;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: var(--font-body);
line-height: 1.5; line-height: 1.55;
letter-spacing: 0.005em;
} }
/* dithered poster band fading from the masthead into the white page */
body::before {
content: "";
position: absolute; top: 0; left: 0; right: 0; height: 220px;
z-index: -1;
pointer-events: none;
background-image: var(--dither);
opacity: 0.5;
-webkit-mask-image: linear-gradient(#000, transparent);
mask-image: linear-gradient(#000, transparent);
}
::selection { background: var(--accent); color: var(--accent-contrast); }
a { color: var(--accent); text-decoration: none; } a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; text-underline-offset: 3px; }
h1, h2, h3 { line-height: 1.25; margin: 0 0 16px; }
h1 { font-size: 1.5rem; } h1, h2, h3 {
h2 { font-size: 1.2rem; } font-family: var(--font-display);
h3 { font-size: 1rem; } font-weight: 600;
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } line-height: 1.15;
letter-spacing: 0.005em;
margin: 0 0 16px;
}
h1 { font-size: 2.1rem; }
h1::after {
content: "";
display: block;
width: 88px; height: 8px;
margin-top: 10px;
background-image: var(--dither-dense);
border-bottom: 2px solid var(--accent);
}
h2 { font-size: 1.25rem; }
h3 { font-size: 1.02rem; }
code, pre { font-family: var(--font-mono); }
:focus-visible { :focus-visible {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 2px; outline-offset: 2px;
} }
/* numerals everywhere read like a ledger */
table, .badge, input[type=number] { font-variant-numeric: tabular-nums; }
/* ---- layout ---- */ /* ---- layout ---- */
.container { max-width: 1100px; margin: 0 auto; padding: 24px 16px; } .container { max-width: 1100px; margin: 0 auto; padding: 32px 16px 64px; }
.narrow { max-width: 460px; } .narrow { max-width: 460px; }
/* staggered page reveal (one orchestrated moment, then calm) */
@media (prefers-reduced-motion: no-preference) {
main.container > * {
animation: rise 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) backwards;
}
main.container > *:nth-child(1) { animation-delay: 0.03s; }
main.container > *:nth-child(2) { animation-delay: 0.09s; }
main.container > *:nth-child(3) { animation-delay: 0.15s; }
main.container > *:nth-child(4) { animation-delay: 0.21s; }
main.container > *:nth-child(5) { animation-delay: 0.27s; }
main.container > *:nth-child(n+6) { animation-delay: 0.33s; }
}
@keyframes rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
/* ---- top navigation: dithered masthead bar ---- */
.topnav { .topnav {
display: flex; align-items: center; gap: 16px; display: flex; align-items: center; gap: 18px;
background: var(--surface); background-color: var(--surface2);
border-bottom: 1px solid var(--border); background-image: var(--dither);
padding: 8px 16px; background-blend-mode: normal;
border-bottom: 2px solid var(--border);
padding: 10px 20px;
position: relative;
} }
.topnav .brand { font-weight: 700; color: var(--text); } :root[data-theme=light] .topnav { background-image: none; background-color: var(--surface2); }
.topnav .links { display: flex; gap: 8px; flex: 1; flex-wrap: wrap; } .topnav::after {
content: "";
position: absolute; bottom: -10px; left: 0; right: 0; height: 8px;
background-image: var(--dither);
-webkit-mask-image: linear-gradient(#000, transparent);
mask-image: linear-gradient(#000, transparent);
pointer-events: none;
}
.topnav::before {
content: "";
position: absolute; top: 0; left: 0; right: 0; height: 3px;
background: var(--accent);
}
.topnav .brand {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.18rem;
color: var(--text);
letter-spacing: 0.01em;
white-space: nowrap;
}
.topnav .brand::before {
content: "◈ ";
color: var(--accent);
}
.topnav .brand:hover { text-decoration: none; }
.topnav .links { display: flex; gap: 2px; flex: 1; flex-wrap: wrap; }
.topnav .links a { .topnav .links a {
color: var(--muted); padding: 6px 10px; border-radius: var(--radius); color: var(--muted);
padding: 7px 11px;
border-radius: var(--radius);
font-weight: 500;
font-size: 0.92rem;
letter-spacing: 0.02em;
border-bottom: 2px solid transparent;
}
.topnav .links a:hover {
color: var(--text);
background: var(--surface2);
text-decoration: none;
}
.topnav .links a[aria-current=page] {
color: var(--text);
border-bottom-color: var(--accent);
} }
.topnav .links a:hover { color: var(--text); background: var(--surface2); text-decoration: none; }
.topnav .links a[aria-current=page] { color: var(--text); background: var(--surface2); }
.topnav .right { display: flex; align-items: center; gap: 8px; } .topnav .right { display: flex; align-items: center; gap: 8px; }
/* ---- components ---- */ /* ---- cards: printed stock with hard Y2K shadows ---- */
.card { .card {
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: var(--hairline);
border-radius: var(--radius); border-radius: var(--radius);
box-shadow: var(--ink-shadow);
padding: 24px; padding: 24px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
} }
.card + .card { margin-top: 16px; } .card + .card { margin-top: 16px; }
/* the stacking margin must never leak into grid/flex layouts — it shifted
every card except the first one ("always the first item" bug) */
.grid > .card, .kanban .card, .row > .card { margin-top: 0; }
.grid > .card { position: relative; overflow: hidden; }
.grid > .card::before {
content: "";
position: absolute; top: 0; left: 0; right: 0; height: 6px;
background-image: var(--dither-dense);
border-bottom: 1px solid var(--accent);
opacity: 0.8;
}
.grid > .card:hover {
transform: translate(-2px, -2px);
box-shadow: var(--ink-shadow-lift);
}
/* ---- buttons: Y2K hard-shadow chips ---- */
.btn { .btn {
display: inline-flex; align-items: center; gap: 8px; display: inline-flex; align-items: center; justify-content: center; gap: 8px;
font: inherit; cursor: pointer; font-family: var(--font-body);
font-size: 0.86rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
white-space: nowrap;
margin: 0; /* label-as-button must not inherit label margins */
padding: 8px 16px; padding: 8px 16px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--surface2); background: var(--surface2);
color: var(--text); color: var(--text);
box-shadow: 2px 2px 0 color-mix(in srgb, var(--border) 60%, transparent);
transition: transform 0.07s ease, box-shadow 0.07s ease, filter 0.12s ease;
} }
.btn:hover { filter: brightness(0.97); } .btn:hover { filter: brightness(0.97); text-decoration: none; }
.btn:active { transform: translate(2px, 2px); box-shadow: none; }
.btn.primary { .btn.primary {
background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); background: var(--accent);
color: var(--accent-contrast);
border-color: color-mix(in srgb, var(--accent) 65%, black);
} }
.btn.danger { background: var(--err); color: #fff; border-color: var(--err); } .btn.danger { background: var(--err); color: #fff; border-color: color-mix(in srgb, var(--err) 65%, black); }
.btn.ghost { background: transparent; } .btn.ghost { background: transparent; border-color: transparent; box-shadow: none; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; } .btn.ghost:hover { border-color: var(--border); }
.btn.small { padding: 4px 8px; font-size: 0.875rem; } .btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
.btn.small { padding: 4px 10px; font-size: 0.74rem; box-shadow: 1px 1px 0 color-mix(in srgb, var(--border) 60%, transparent); }
label { display: block; font-weight: 600; margin-bottom: 4px; } /* ---- forms ---- */
.hint { color: var(--muted); font-size: 0.875rem; margin: 4px 0 0; } label { display: block; font-weight: 700; font-size: 0.88rem; letter-spacing: 0.02em; margin-bottom: 5px; }
.hint { color: var(--muted); font-size: 0.86rem; margin: 5px 0 0; }
input[type=text], input[type=email], input[type=password], input[type=number], input[type=text], input[type=email], input[type=password], input[type=number],
select, textarea { input[type=search], input[type=date], input[type=file], select, textarea {
width: 100%; width: 100%;
font: inherit; color: var(--text); font: inherit; color: var(--text);
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: var(--hairline);
border-radius: var(--radius); border-radius: var(--radius);
padding: 8px; padding: 9px 10px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
} }
textarea { resize: vertical; min-height: 80px; } input:focus, select:focus, textarea:focus {
.field { margin-bottom: 16px; } border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
outline: none;
}
textarea { resize: vertical; min-height: 84px; }
.field { margin-bottom: 18px; }
.row { display: flex; gap: 16px; } .row { display: flex; gap: 16px; }
.row > * { flex: 1; } .row > * { flex: 1; }
.error-box, .ok-box { .error-box, .ok-box {
border: 1px solid var(--err); color: var(--err); border: 1px solid var(--err);
border-left-width: 4px;
color: var(--err);
background: color-mix(in srgb, var(--err) 7%, var(--surface));
border-radius: var(--radius); border-radius: var(--radius);
padding: 8px 12px; margin-bottom: 16px; padding: 10px 14px;
margin-bottom: 16px;
font-weight: 500;
}
.ok-box {
border-color: var(--ok); color: var(--ok);
background: color-mix(in srgb, var(--ok) 8%, var(--surface));
} }
.ok-box { border-color: var(--ok); color: var(--ok); }
.error-box:empty, .ok-box:empty { display: none; } .error-box:empty, .ok-box:empty { display: none; }
/* ---- badges: rubber stamps & banknote chips ---- */
.badge { .badge {
display: inline-block; font-size: 0.75rem; font-weight: 600; display: inline-block;
padding: 2px 8px; border-radius: var(--radius); font-family: var(--font-mono);
background: var(--surface2); color: var(--muted); font-size: 0.7rem;
font-weight: 500;
letter-spacing: 0.09em;
text-transform: uppercase;
padding: 2px 8px;
border-radius: var(--radius);
background: transparent;
color: var(--muted);
border: 1px solid var(--border); border: 1px solid var(--border);
} }
.badge.accent { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); } .badge.accent {
font-weight: 700;
background: var(--surface);
color: var(--accent);
border: 1px solid var(--accent);
box-shadow: inset 0 0 0 2px var(--surface), inset 0 0 0 3px color-mix(in srgb, var(--accent) 45%, transparent);
padding: 3px 10px;
}
.avatar { .avatar {
width: 32px; height: 32px; border-radius: var(--radius); width: 32px; height: 32px; border-radius: var(--radius);
background: var(--accent); color: var(--accent-contrast); background: var(--accent); color: var(--accent-contrast);
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 0.875rem; font-family: var(--font-display);
font-weight: 700; font-size: 0.9rem;
object-fit: cover; overflow: hidden; object-fit: cover; overflow: hidden;
border: 1px solid color-mix(in srgb, var(--accent) 70%, black);
} }
.avatar.large { width: 96px; height: 96px; font-size: 2rem; } .avatar.large { width: 96px; height: 96px; font-size: 2.1rem; }
img.avatar { background: var(--surface2); } img.avatar { background: var(--surface2); }
/* ---- tables: the ledger itself ---- */
table.list { width: 100%; border-collapse: collapse; } table.list { width: 100%; border-collapse: collapse; }
table.list th, table.list td { table.list th, table.list td {
text-align: left; padding: 8px; text-align: left; padding: 9px 10px;
border-bottom: 1px solid var(--border); border-bottom: var(--hairline);
} }
table.list th { color: var(--muted); font-size: 0.875rem; } table.list th {
font-family: var(--font-mono);
color: var(--muted);
font-size: 0.72rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.1em;
border-bottom: 2px solid var(--border);
}
table.list tbody tr:hover { background: color-mix(in srgb, var(--surface2) 60%, transparent); }
table.list tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surface2) 28%, transparent); }
.grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } .grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.grid > .card { display: flex; flex-direction: column; } /* equal-height rows */
/* ---- toolbar: filter rows boxed and baseline-aligned ---- */
.toolbar {
display: flex; gap: 14px; align-items: flex-end; flex-wrap: wrap;
background: var(--surface);
border: var(--hairline);
border-radius: var(--radius);
box-shadow: var(--ink-shadow);
padding: 14px 16px;
position: relative;
overflow: hidden;
}
.toolbar::before {
content: "";
position: absolute; top: 0; left: 0; right: 0; height: 5px;
background-image: var(--dither-dense);
opacity: 0.7;
}
.toolbar .field { margin-bottom: 0; flex: 1 1 150px; }
.toolbar .field.tight { flex: 0 1 auto; }
.toolbar > .btn { flex: 0 0 auto; height: 41px; } /* matches input height */
.toolbar label { white-space: nowrap; }
/* ---- stat cards (metrics) ---- */
.stat { display: flex; flex-direction: column; gap: 4px; }
.stat .stat-label {
font-family: var(--font-mono);
font-size: 0.7rem; font-weight: 500;
letter-spacing: 0.12em; text-transform: uppercase;
color: var(--muted);
margin: 0;
}
.stat .stat-value {
font-family: var(--font-display);
font-size: 1.9rem; font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.1;
margin: 0;
}
.stat .hint { margin-top: auto; }
.muted { color: var(--muted); } .muted { color: var(--muted); }
.spread { display: flex; justify-content: space-between; align-items: center; gap: 16px; } .spread { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
@@ -140,57 +397,167 @@ table.list th { color: var(--muted); font-size: 0.875rem; }
.kv-row { display: flex; gap: 8px; margin-bottom: 8px; } .kv-row { display: flex; gap: 8px; margin-bottom: 8px; }
.kv-row input { flex: 1; } .kv-row input { flex: 1; }
/* toast notifications */ /* ---- toasts: telegram slips (above the chat bubble) ---- */
#toasts { #toasts {
position: fixed; bottom: 16px; right: 16px; z-index: 100; position: fixed; bottom: 84px; right: 16px; z-index: 2147483647;
display: flex; flex-direction: column; gap: 8px; max-width: 360px; display: flex; flex-direction: column; gap: 8px; max-width: 360px;
} }
.toast { .toast {
background: var(--surface); border: 1px solid var(--border); background: var(--surface);
border-left: 3px solid var(--accent); border: var(--hairline);
border-left: 4px solid var(--accent);
border-radius: var(--radius); border-radius: var(--radius);
padding: 12px 16px; padding: 12px 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15); font-size: 0.92rem;
box-shadow: 0 10px 24px -10px rgba(0,0,0,0.4);
animation: slipIn 0.25s cubic-bezier(0.2, 0.7, 0.2, 1);
}
@keyframes slipIn {
from { opacity: 0; transform: translateX(12px); }
to { opacity: 1; transform: none; }
} }
.toast.err { border-left-color: var(--err); } .toast.err { border-left-color: var(--err); }
.toast.ok { border-left-color: var(--ok); } .toast.ok { border-left-color: var(--ok); }
/* kanban */ /* ---- masthead (login / register) ---- */
.kanban { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } .masthead { text-align: center; margin-bottom: 26px; }
.kanban-col { .masthead-kicker {
background: var(--surface2); border: 1px solid var(--border); font-family: var(--font-mono);
border-radius: var(--radius); padding: 16px; min-height: 200px; font-size: 0.68rem;
letter-spacing: 0.32em;
text-transform: uppercase;
color: var(--muted);
margin: 0 0 10px;
}
.masthead-title {
font-family: var(--font-display);
font-weight: 700;
font-size: 2.6rem;
letter-spacing: 0.01em;
margin: 0;
}
.masthead-title::after { content: none; }
.masthead-rule {
display: flex; align-items: center; gap: 10px;
margin: 14px auto 0; max-width: 280px;
color: var(--accent);
}
.masthead-rule::before, .masthead-rule::after {
content: "";
flex: 1;
border-top: var(--hairline);
border-bottom: var(--hairline);
height: 3px;
} }
.kanban-col h2 { font-size: 0.9rem; text-transform: uppercase; color: var(--muted); }
/* notification bell */ /* ---- hover cards ---- */
.hovercard {
position: absolute; z-index: 90; width: 300px;
box-shadow: 0 14px 30px -12px rgba(0,0,0,0.45);
animation: rise 0.18s ease;
}
/* ---- chat ---- */
.chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 18px; min-height: 70vh; }
.chat-sidebar { overflow: auto; }
.conv-list { list-style: none; margin: 8px 0 0; padding: 0; }
.conv-list li { border-bottom: var(--hairline); }
.conv-list li.active .conv-item {
background: var(--surface2);
box-shadow: inset 3px 0 0 var(--accent);
}
.conv-item {
display: flex; justify-content: space-between; align-items: center; gap: 8px;
width: 100%; padding: 11px 10px; font: inherit; text-align: left;
background: none; border: none; color: var(--text); cursor: pointer;
border-radius: var(--radius);
}
.conv-item:hover { background: var(--surface2); }
.chat-main { display: flex; flex-direction: column; }
.chat-messages { flex: 1; overflow: auto; padding: 8px 0; min-height: 320px; }
.chat-msg {
max-width: 75%; margin: 10px 0; padding: 9px 13px;
background: var(--surface2);
border: var(--hairline);
border-radius: var(--radius);
font-size: 0.95rem;
}
.chat-msg.own {
margin-left: auto;
background: color-mix(in srgb, var(--accent) 11%, var(--surface2));
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
}
.chat-msg p, .chat-msg ul, .chat-msg ol { margin: 4px 0; }
.chat-img { max-width: 240px; max-height: 180px; cursor: zoom-in; border-radius: var(--radius); border: var(--hairline); }
.chat-toolbar { display: flex; gap: 4px; margin-bottom: 8px; }
.chat-composer {
min-height: 60px; max-height: 200px; overflow: auto;
background: var(--bg);
border: var(--hairline);
border-radius: var(--radius);
padding: 10px;
}
.chat-composer:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
outline: none;
}
.chat-composer:empty::before { content: attr(data-placeholder); color: var(--muted); }
.chat-attachments { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.lightbox { border: none; background: transparent; max-width: 92vw; max-height: 92vh; }
.lightbox img { max-width: 90vw; max-height: 88vh; cursor: zoom-out; border: 4px solid var(--surface); }
.lightbox::backdrop { background: rgba(12, 10, 8, 0.85); }
@media (max-width: 800px) { .chat-layout { grid-template-columns: 1fr; } }
/* ---- kanban: ledger columns ---- */
.kanban { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.kanban-col {
background: color-mix(in srgb, var(--surface2) 55%, transparent);
border: var(--hairline);
border-top: 3px double var(--border);
border-radius: var(--radius);
padding: 16px;
min-height: 220px;
}
.kanban-col h2 {
font-family: var(--font-mono);
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--muted);
}
/* ---- notification bell ---- */
.bell-badge { .bell-badge {
position: absolute; top: -4px; right: -4px; position: absolute; top: -4px; right: -4px;
background: var(--err); color: #fff; background: var(--err); color: #fff;
font-size: 0.65rem; font-weight: 700; font-family: var(--font-mono);
font-size: 0.62rem; font-weight: 700;
padding: 1px 5px; border-radius: var(--radius); padding: 1px 5px; border-radius: var(--radius);
} }
.notif-panel { .notif-panel {
position: absolute; right: 0; top: 36px; z-index: 50; position: absolute; right: 0; top: 38px; z-index: 50;
width: 320px; max-height: 420px; overflow: auto; width: 330px; max-height: 420px; overflow: auto;
background: var(--surface); border: 1px solid var(--border); background: var(--surface);
border-radius: var(--radius); padding: 12px; border: var(--hairline);
box-shadow: 0 4px 16px rgba(0,0,0,0.2); border-top: 3px double var(--border);
border-radius: var(--radius);
padding: 12px;
box-shadow: 0 16px 32px -12px rgba(0,0,0,0.45);
animation: rise 0.18s ease;
} }
.notif-list { list-style: none; margin: 0 0 8px; padding: 0; } .notif-list { list-style: none; margin: 0 0 8px; padding: 0; }
.notif-list li { padding: 8px 0; border-bottom: 1px solid var(--border); } .notif-list li { padding: 9px 0; border-bottom: var(--hairline); }
.notif-list a { color: var(--text); text-decoration: none; } .notif-list a { color: var(--text); }
.notif-list a:hover { text-decoration: none; opacity: 0.85; } .notif-list a:hover { text-decoration: none; opacity: 0.85; }
/* atomizing progress shimmer */ /* ---- atomizing shimmer ---- */
.shimmer { .shimmer { position: relative; overflow: hidden; }
position: relative;
overflow: hidden;
}
.shimmer::after { .shimmer::after {
content: ""; content: "";
position: absolute; inset: 0; position: absolute; inset: 0;
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent) 12%, transparent), transparent); background: linear-gradient(100deg, transparent 30%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 70%);
animation: shimmer 1.6s infinite; animation: shimmer 1.6s infinite;
} }
@keyframes shimmer { @keyframes shimmer {
@@ -199,22 +566,132 @@ table.list th { color: var(--muted); font-size: 0.875rem; }
} }
input[type=range] { width: 100%; accent-color: var(--accent); } input[type=range] { width: 100%; accent-color: var(--accent); }
.tabs { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; } /* ---- tabs ---- */
.tabs [aria-selected=true] { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); } .tabs { display: flex; gap: 8px; margin-bottom: 18px; flex-wrap: wrap; }
.tabs [aria-selected=true] {
background: var(--accent);
color: var(--accent-contrast);
border-color: color-mix(in srgb, var(--accent) 70%, black);
}
/* ---- dialogs: paper slips ---- */
dialog { dialog {
background: var(--surface); color: var(--text); background: var(--surface); color: var(--text);
border: 1px solid var(--border); border-radius: var(--radius); border: var(--hairline);
padding: 24px; max-width: 90vw; max-height: 90vh; overflow: auto; border-top: 4px double var(--accent);
border-radius: var(--radius);
padding: 26px;
max-width: 90vw; max-height: 90vh; overflow: auto;
box-shadow: 0 30px 70px -20px rgba(0,0,0,0.55);
} }
dialog::backdrop { background: rgba(0,0,0,0.4); } dialog[open] { animation: rise 0.22s cubic-bezier(0.2, 0.7, 0.2, 1); }
dialog::backdrop { background: rgba(20, 16, 12, 0.55); }
fieldset { fieldset {
border: 1px solid var(--border); border-radius: var(--radius); border: var(--hairline);
border-radius: var(--radius);
margin: 0 0 16px; padding: 16px; margin: 0 0 16px; padding: 16px;
} }
legend { font-weight: 600; padding: 0 8px; } legend {
font-family: var(--font-mono);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
padding: 0 8px;
}
/* ---- new-conversation dialog: fixed geometry, no jumping ---- */
#newconv-form { width: 440px; max-width: 86vw; }
#nc-results {
height: 180px; /* fixed: search results never resize the dialog */
overflow: auto;
border: var(--hairline);
border-radius: var(--radius);
background: var(--bg);
margin-top: 8px;
padding: 4px;
}
#nc-results .nc-row {
display: block; width: 100%;
text-align: left;
font: inherit; font-size: 0.92rem;
background: none; border: none; color: var(--text);
padding: 8px 10px; cursor: pointer;
border-bottom: var(--hairline);
border-radius: var(--radius);
}
#nc-results .nc-row:hover { background: var(--surface2); }
#nc-results .nc-empty { color: var(--muted); padding: 10px; font-size: 0.88rem; }
#nc-selected { min-height: 30px; display: flex; gap: 6px; flex-wrap: wrap; }
/* ---- floating messages bubble + mini panel ---- */
.chat-fab {
position: fixed; bottom: 16px; right: 16px; z-index: 2147483645;
width: 52px; height: 52px;
display: flex; align-items: center; justify-content: center;
font-size: 1.35rem;
background: var(--accent); color: var(--accent-contrast);
border: 1px solid color-mix(in srgb, var(--accent) 65%, black);
border-radius: var(--radius);
box-shadow: 3px 3px 0 color-mix(in srgb, var(--border) 70%, transparent);
cursor: pointer;
transition: transform 0.08s ease, box-shadow 0.08s ease;
}
.chat-fab:hover { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 color-mix(in srgb, var(--border) 70%, transparent); }
.chat-fab:active { transform: translate(2px, 2px); box-shadow: none; }
.chat-fab .bell-badge { top: -6px; right: -6px; }
.chat-panel {
position: fixed; bottom: 80px; right: 16px; z-index: 2147483645;
width: 350px; height: 480px; max-height: 75vh;
display: flex; flex-direction: column;
background: var(--surface);
border: var(--hairline);
border-top: 4px double var(--accent);
border-radius: var(--radius);
box-shadow: 6px 6px 0 color-mix(in srgb, var(--border) 50%, transparent);
animation: rise 0.18s ease;
}
.chat-panel header {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 10px 12px;
border-bottom: 2px solid var(--border);
background-image: var(--dither);
background-color: var(--surface2);
}
.chat-panel header strong {
font-family: var(--font-display); font-size: 1rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.chat-panel header .btn { background: var(--surface); }
.chat-panel .cw-body { flex: 1; overflow: auto; padding: 6px 10px; }
.chat-panel .cw-conv {
display: flex; justify-content: space-between; align-items: center; gap: 8px;
width: 100%; padding: 10px 8px; font: inherit; text-align: left;
background: none; border: none; border-bottom: var(--hairline);
color: var(--text); cursor: pointer; border-radius: var(--radius);
}
.chat-panel .cw-conv:hover { background: var(--surface2); }
.chat-panel .cw-msg {
max-width: 85%; margin: 8px 0; padding: 7px 10px;
font-size: 0.88rem;
background: var(--surface2);
border: var(--hairline);
border-radius: var(--radius);
}
.chat-panel .cw-msg.own {
margin-left: auto;
background: color-mix(in srgb, var(--accent) 11%, var(--surface2));
}
.chat-panel .cw-msg .cw-who { color: var(--muted); font-size: 0.72rem; margin: 0 0 2px; font-family: var(--font-mono); }
.chat-panel footer { padding: 10px; border-top: var(--hairline); display: flex; gap: 8px; }
.chat-panel footer input { flex: 1; }
/* ---- scrollbars ---- */
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
@media (max-width: 640px) { @media (max-width: 640px) {
.row { flex-direction: column; gap: 0; } .row { flex-direction: column; gap: 0; }
.topnav { flex-wrap: wrap; } .topnav { flex-wrap: wrap; }
h1 { font-size: 1.6rem; }
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2
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') }; return { organization: val('c-ado-org'), project: val('c-ado-project'), pat: val('c-ado-pat') };
case 'youtrack': case 'youtrack':
return { permanentToken: val('c-yt-token') }; return { permanentToken: val('c-yt-token') };
case 'wekan':
return { username: val('c-wekan-user'), password: val('c-wekan-pass') };
default: default:
return {}; return {};
} }
+4
View File
@@ -149,6 +149,8 @@ function renderChildren(parent, kids, depth) {
</label> </label>
<span> <span>
<button class="btn small" data-act="edit">Edit</button> <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> <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>' : ''} ${k.status === 'atomized' ? '<button class="btn small primary" data-act="publish">Publish</button>' : ''}
<button class="btn small" data-act="archive">Archive</button> <button class="btn small" data-act="archive">Archive</button>
@@ -183,6 +185,8 @@ function renderChildren(parent, kids, depth) {
}); });
} }
row.querySelector('[data-act=edit]').addEventListener('click', () => openEdit(k, false)); 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=extend]').addEventListener('click', () => openExtend(k));
row.querySelector('[data-act=archive]').addEventListener('click', () => archive(k)); row.querySelector('[data-act=archive]').addEventListener('click', () => archive(k));
const pub = row.querySelector('[data-act=publish]'); const pub = row.querySelector('[data-act=publish]');
+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);
});
}
+154
View File
@@ -0,0 +1,154 @@
// 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';
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>' + esc(text) + '</p>',
});
} catch (e) { input.value = text; }
}
panel.querySelector('#cw-send').addEventListener('click', send);
panel.querySelector('#cw-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); send(); }
});
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;
}
});
+306
View File
@@ -0,0 +1,306 @@
import { api, toast } from '/static/js/api.js';
import { subscribe, send as wsSend, onPollFallback } from '/static/js/ws.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 = '';
document.getElementById('chat-title').textContent = c.title;
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);
}
async function loadMessages(prepend) {
const res = await api('GET',
`/api/v1/conversations/${current.id}/messages?cursor=${prepend ? oldestCursor : ''}`);
const msgs = res.messages;
if (msgs.length) oldestCursor = msgs[0].id;
const nodes = await Promise.all(msgs.map(renderMessage));
if (prepend) msgHost.prepend(...nodes);
else {
msgHost.append(...nodes);
msgHost.scrollTop = msgHost.scrollHeight;
}
}
// 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 (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
form.requestSubmit();
}
});
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) {
msgHost.appendChild(await renderMessage(data));
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'); }
});
(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();
})();
+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; }
});
+83
View File
@@ -0,0 +1,83 @@
// 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]));
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]);
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, 160))}</p>` : ''}
${c.contact && c.contact.location ? `<p class="muted">📍 ${esc(c.contact.location)}</p>` : ''}`;
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);
}
});
+7
View File
@@ -31,6 +31,7 @@
<option value="jira">Jira Cloud</option> <option value="jira">Jira Cloud</option>
<option value="azure_devops">Azure DevOps</option> <option value="azure_devops">Azure DevOps</option>
<option value="youtrack">YouTrack</option> <option value="youtrack">YouTrack</option>
<option value="wekan">WeKan</option>
<option value="demo">Demo (offline)</option> <option value="demo">Demo (offline)</option>
</select> </select>
</div> </div>
@@ -58,6 +59,12 @@
<legend>YouTrack credentials</legend> <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> <div class="field"><label for="c-yt-token">Permanent token</label><input type="password" id="c-yt-token" autocomplete="off"></div>
</fieldset> </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> <fieldset id="creds-demo" class="creds" hidden>
<legend>Demo</legend> <legend>Demo</legend>
<p class="hint">No credentials required — tickets are fabricated locally.</p> <p class="hint">No credentials required — tickets are fabricated locally.</p>
+2 -4
View File
@@ -2,7 +2,7 @@
<h1>Bounty Board</h1> <h1>Bounty Board</h1>
<div class="error-box" id="error" role="alert"></div> <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"> <div class="field">
<label for="f-q">Search</label> <label for="f-q">Search</label>
<input type="search" id="f-q" placeholder="Search title or description… ( / )"> <input type="search" id="f-q" placeholder="Search title or description… ( / )">
@@ -22,9 +22,7 @@
<option value="bounty">Highest bounty</option> <option value="bounty">Highest bounty</option>
</select> </select>
</div> </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>
<button class="btn" id="f-save" title="Save current filters as default">Save</button>
</div>
</div> </div>
<div class="grid mt" id="board-grid"></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}}
+3 -1
View File
@@ -57,7 +57,9 @@
</main> </main>
<div id="toasts" aria-live="polite"></div> <div id="toasts" aria-live="polite"></div>
<script type="module" src="/static/js/nav.js"></script> <script type="module" src="/static/js/nav.js"></script>
{{if .User}}<script type="module" src="/static/js/notifications.js"></script>{{end}} {{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> {{range .Scripts}}<script type="module" src="{{.}}"></script>
{{end}} {{end}}
</body> </body>
+6 -1
View File
@@ -1,4 +1,9 @@
{{define "content"}} {{define "content"}}
<header class="masthead">
<p class="masthead-kicker">Consulting Work Exchange</p>
<p class="masthead-title">◈ Bounty Board</p>
<p class="masthead-rule"></p>
</header>
<div class="card"> <div class="card">
<h1>Log in</h1> <h1>Log in</h1>
<div class="error-box" id="error" role="alert">{{.Error}}</div> <div class="error-box" id="error" role="alert">{{.Error}}</div>
@@ -13,7 +18,7 @@
</div> </div>
<div class="spread"> <div class="spread">
<button class="btn primary" type="submit">Log in</button> <button class="btn primary" type="submit">Log in</button>
<a href="/register">Create an account</a> <span><a href="/forgot-password">Forgot password?</a> · <a href="/register">Create an account</a></span>
</div> </div>
</form> </form>
{{if .OIDCEnabled}} {{if .OIDCEnabled}}
+72
View File
@@ -0,0 +1,72 @@
{{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>
<ul id="conv-list" class="conv-list"></ul>
</aside>
<section class="chat-main card">
<div class="spread">
<h2 id="chat-title">Select a conversation</h2>
<span class="muted" id="typing-indicator" aria-live="polite"></span>
</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="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}}
+1 -1
View File
@@ -57,7 +57,7 @@
<div class="field"> <div class="field">
<label for="p-theme">Theme</label> <label for="p-theme">Theme</label>
<select id="p-theme"> <select id="p-theme">
<option value="light" {{if eq .User.Settings.Theme "light"}}selected{{end}}>Light (beige)</option> <option value="light" {{if eq .User.Settings.Theme "light"}}selected{{end}}>Light (Y2K paper)</option>
<option value="dark" {{if eq .User.Settings.Theme "dark"}}selected{{end}}>Dark</option> <option value="dark" {{if eq .User.Settings.Theme "dark"}}selected{{end}}>Dark</option>
</select> </select>
</div> </div>
+5
View File
@@ -1,4 +1,9 @@
{{define "content"}} {{define "content"}}
<header class="masthead">
<p class="masthead-kicker">Consulting Work Exchange</p>
<p class="masthead-title">◈ Bounty Board</p>
<p class="masthead-rule"></p>
</header>
<div class="card"> <div class="card">
<h1>Create account</h1> <h1>Create account</h1>
<p class="hint">Self-registration creates a developer account. Consultant and admin roles are granted by an administrator.</p> <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}}