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 ---
# Set APP_BASE_URL to the public https URL when behind the reverse proxy.
APP_BASE_URL=http://localhost:8787
# In-network address external services use to call back into the app.
# docker-compose.yml sets this to http://app:8787; defaults to APP_BASE_URL.
#APP_INTERNAL_URL=
# Comma-separated CIDRs of trusted reverse proxies, e.g. 172.16.0.0/12,10.0.0.0/8.
# Empty = trust no proxy headers.
TRUSTED_PROXY_CIDRS=
+62
View File
@@ -104,3 +104,65 @@ Spec-silent choices, recorded as required by the build instructions.
/change-password.
- **Pages for later phases render an "under construction" placeholder** so
role-based navigation is complete and clickable now.
## Phases 512 (selected)
- **`internal/extsvc`** (not in the §14 list) holds the circuit breaker and
retrying JSON client shared by the two service clients — sharing plumbing,
not state; each service keeps its own breaker, URL, and token.
- **Mock services are separate codebases**: `services/atomizer` has its own
`go.mod` (stdlib only); `services/work-performer` is plain Node with zero
npm dependencies (plus the globally installed claude CLI in its image).
- **AI-approved tasks earn no `bountyAwards` row** — §4.5 awards are a
developer performance ledger; the AI submitter has no developer identity.
- **Bulk archive/publish are partial-success APIs** returning
`{done[], failed{}}` instead of failing the whole batch.
- **Extension siblings record `parentId = source task`** (spec literal),
so the UI tree shows extensions beneath their source.
## Phase 13 (deployment)
- **`APP_INTERNAL_URL` (compose: `http://app:8787`)** is handed to the
external services for callback URLs and signed attachment URLs — the §5.2
example uses the in-network hostname; `APP_BASE_URL` stays browser-facing.
- **The work-performer container runs as the `node` user** (uid 1000) with
`${HOME}/.claude` mounted into `/home/node/` instead of `/root/` (§9.2
shows /root): the claude CLI refuses `--dangerously-skip-permissions` as
root, so the literal spec mount can never execute jobs.
- **Run compose with the real user's HOME** — `sudo docker compose` resolves
`${HOME}` to `/root` and silently mounts the wrong Claude credentials. Use
`sudo --preserve-env=HOME docker compose …` (or run docker unprivileged).
- **UFW**: a rule allowing `172.16.0.0/12` (docker networks) to reach the
host was added so container→host callbacks work in contract tests.
- **`scripts/acceptance.sh`** automates the §13 checklist live (import →
subdivide → extend → publish → claim/decline/approve → review → award →
AI job via real Claude Code → breaker independence) and is re-run-safe.
## Post-deploy additions
- **WeKan connector**: `projectKey` is the board id; "assigned to the
consultant" means the consultant's WeKan username (from
`ticketingIdentities.wekan`) appears in a card's `assignees` (falling back
to `members` when no assignee is set). Archived cards are skipped. WeKan
has no epic/story hierarchy → all cards map to type `task`; the API offers
no server-side updated-since filter, so the connector filters on
`modifiedAt` client-side (one details request per card — fine for board
sizes WeKan handles). Auth: username+password login per sync with a 10-min
token reuse window, or a pre-issued token. Card attachments are not
imported in v1.
## Remote access + UI design pass (user-requested)
- **App published on 0.0.0.0:8787** (spec default loopback-only kept as a
commented line in compose): this host's pattern exposes services directly
(gitea/outline/wekan) and Docker-published ports bypass UFW anyway. The
public entrypoint is Nginx Proxy Manager → http://bountyboard.anypreta.com
with `APP_BASE_URL` set accordingly and `TRUSTED_PROXY_CIDRS=172.16.0.0/12`
so audit-log client IPs resolve through the proxy.
- **"The Ledger" design pass** (user invoked the frontend-design skill):
§10 tokens, 2px radius, AA contrast and both themes unchanged; typography
deviates from the spec's system font stack by request — self-hosted
variable woff2 (Fraunces display, Schibsted Grotesk body, Spline Sans Mono
for ledger numerals), ~147 KB total, no build step, CSP font-src 'self'.
Adds paper grain, hairline double rules, letterpress buttons, stamp
badges, staggered page reveal (disabled under prefers-reduced-motion).
+17 -3
View File
@@ -4,6 +4,7 @@ GO ?= go
build:
$(GO) build ./...
cd services/atomizer && $(GO) build ./...
run:
$(GO) run ./cmd/app
@@ -16,19 +17,32 @@ test:
test-integration:
$(GO) test -tags=integration ./...
# Requires the mock services running:
# docker compose --profile mocks up -d --build atomizer-mock work-performer
test-contract:
ATOMIZER_TOKEN=$$(grep '^ATOMIZER_TOKEN=' .env | cut -d= -f2-) \
WORK_PERFORMER_TOKEN=$$(grep '^WORK_PERFORMER_TOKEN=' .env | cut -d= -f2-) \
$(GO) test -tags=contract -count=1 -timeout=8m ./internal/contract/
lint:
@unformatted=$$(gofmt -l .); if [ -n "$$unformatted" ]; then \
echo "gofmt needed on:"; echo "$$unformatted"; exit 1; fi
$(GO) vet ./...
# Requires Mongo published on loopback (test overlay) or MONGO_SEED_URI.
seed:
@echo "seed script lands in phase 12 (scripts/seed.go)"; exit 1
$(GO) run ./scripts
smoke:
./scripts/smoke.sh
# mongodump/mongorestore through the mongo container into ./backups (§11.23)
backup:
@echo "backup target lands in phase 12"; exit 1
@mkdir -p backups
docker compose exec -T mongo mongodump --archive --db=bountyboard > backups/bountyboard-$$(date +%Y%m%d-%H%M%S).archive
@ls -lh backups/ | tail -1
# usage: make restore FILE=backups/bountyboard-20260612-120000.archive
restore:
@echo "restore target lands in phase 12"; exit 1
@test -n "$(FILE)" || (echo "usage: make restore FILE=backups/<archive>"; exit 1)
docker compose exec -T mongo mongorestore --archive --drop < $(FILE)
+9
View File
@@ -9,3 +9,12 @@
- Phase 6 (sync workers): per-customer pollers with reconcile loop (start/stop/interval changes), §5.3 idempotent upsert keyed (system,key,customerId) w/ content-hash change detection, upstream edits refresh only while imported (timeline+notification after), attachment caching to GridFS, orphan flagging (never deletes), admin sync-now trigger + worker statuses, default budget prefill — demo-type integration tests green.
- Phase 7 (atomization): circuit breaker (3 fails→open, 60s half-open probe) + retrying bearer JSON client shared by both external services, atomizer client w/ §5.1 coefficient normalization (±0.001 ok, ≤0.05 renormalized, else 502-class), persisted jobs queue (panic-safe, backoff ×3, stale requeue, restart-safe), subdivide/extend endpoints (202 async, re-run replaces unpublished children after confirm), task editing w/ bounty recompute + optional budget cascade, publish single/bulk, WS hub (origin check, 30s heartbeats) + live board, consultant atomization board UI (tree, sliders, sum indicator, modals, shimmer, health gating) — unit + integration green. Fixed tasks unique index: sparse→partial (sparse compound matched every task via customerId).
- Phase 8 (bounty board + lifecycle): developer board (pool-scoped, filters/search/sort, stale age badges, hide-competing-claims setting, saved filters), claim/withdraw/decline/approve, assign-to-AI via §5.2 client + HMAC-verified idempotent callback w/ artifact ingestion into GridFS, start/submit/abandon/unassign, comments (sanitized, @mentions→notifications), time log, review queue + per-AC checklist on timeline, immutable bountyAwards on approve (humans only), notifications center + bell + WS toasts, my-tasks kanban, task detail page, pool management UI, server-side HTML sanitizer w/ XSS vector tests — full lifecycle integration tests green.
- Phase 9 (messaging): conversations (dm dedupe/group/project), sanitized rich-text messages, file/image attachments (generic POST /api/v1/files w/ rate limit), per-conversation unread counts + mark-read, chat-file access limited to participants, mentions→notifications, typing indicator over WS inbound relay, two-pane messages UI (contenteditable composer, formatting toolbar, drag&drop upload, inline previews + lightbox, live delivery, polling fallback), user directory search — integration tests green.
- Phase 10 (metrics): aggregation pipelines over bountyAwards + timelines (totals, weekly $dateTrunc buckets, per-developer/per-customer, approval rate, time logged, assign→approve lead time, atomization lead time, open board depth), CSV export, leaderboard w/ opt-out (profile setting), dependency-free SVG line+bar charts, developer/consultant/admin dashboards — integration tests green.
- Phase 11 (mock services): services/atomizer (own Go module, ports 8090, bearer auth, OpenAI-compatible chat completions + native /v1/messages via LLM_API_STYLE, strict-JSON prompt, fence-stripping defensive parse, one retry, deterministic equal-split fallback, exact sum normalization); services/work-performer (Node 20, zero deps, single-concurrency queue, TASK.md + attachment download + optional git clone, claude -p --output-format json --dangerously-skip-permissions, simulated result when CLI unavailable, artifact serving, HMAC-signed callbacks with retry); compose profile mocks w/ healthchecks + ${HOME}/.claude mounts; contract tests pass live (WP ran real Claude Code via mounted host creds). UFW rule added for container→host callbacks.
- Phase 12 (polish): seed script (make seed: demo customer + 1 admin + 2 consultants + 6 devs + pools + conversations, idempotent), forgot/reset password (SMTP-gated, one-shot TTL tokens, anti-enumeration), profile hover cards, keyboard shortcuts (g b / g m / g t / g h / focus search), bulk archive endpoint, hand-written api/openapi.yaml served at /api/docs, make backup/restore via mongodump, README w/ runbook + Caddy & nginx samples (WS + client_max_body_size), extended register→login→board smoke script — all tests green.
- Phase 13 (test & deploy): full unit (10 pkgs) + integration (12 pkgs) + contract suites green; scripts/acceptance.sh automates the §13 checklist live and PASSES end-to-end incl. a real Claude Code AI work-performer run; readyz verified 503/200 across a Mongo stop/start; smoke PASS; stack left running with mocks profile. Fixes: APP_INTERNAL_URL for in-network callbacks/signed URLs, work-performer runs as node user (claude refuses root), sudo HOME gotcha documented, UFW rule for container→host callbacks.
- Post-deploy: ANTHROPIC_API_KEY wired into .env (gitignored) — atomizer now produces real claude-sonnet-4-6 subdivisions (verified live, sum=1.0). Added WeKan ticketing source: connector (login or pre-issued token, board=projectKey, cards assigned to consultant's wekan username w/ member fallback, archived skipped, modifiedAt since-filter, token reuse), fake-server unit tests, admin wizard fields, OpenAPI/README updates.
- WeKan live verification against /opt/wekan (v9.36): connector live test green (assignee + member-fallback import, since-filter, identity rejection); full app E2E green (test-connection, customer create, sync worker import, orphan flagging after upstream unassign, real LLM subdivision of a WeKan card). Fixed live-found bug: nested extra.ticketingIdentities decodes as bson.D, identity lookup now handles bson.D/bson.M/map (regression test added).
- Remote access: NPM proxy host bountyboard.anypreta.com → 8787 (app published on 0.0.0.0, APP_BASE_URL + TRUSTED_PROXY_CIDRS=172.16.0.0/12 set, client IPs verified through proxy). UI design pass 'The Ledger': self-hosted Fraunces/Schibsted Grotesk/Spline Sans Mono, paper grain, double rules, letterpress buttons, stamp badges, staggered reveal — §10 tokens/contrast unchanged, screenshots verified light+dark.
- UI iteration: granted all roles to spam@marsal.xyz; Y2K white/brown dithered theme (user-requested §10 deviation; tokens+test updated); boxed .toolbar for board/metrics filters w/ baseline alignment; stat cards (.stat) equal-height/aligned; fixed root causes: .card+.card margin leaking into grids ('always the first item'), CSP style-src blocking ALL inline style attributes (now 'unsafe-inline' for styles only, scripts stay strict), display:flex defeating [hidden]; new-conversation dialog fixed geometry + row list; floating messages bubble w/ unread badge + mini panel (list/thread/composer, WS live) on every page.
+194
View File
@@ -0,0 +1,194 @@
# Bounty Board
A consulting work-management platform: imports tickets from Jira / Azure
DevOps / YouTrack / WeKan (or an offline `demo` source), lets **consultants** atomize
them into small developer tasks with AI assistance, publishes them on a
**bounty board** where **developers** claim work (or an **AI work performer**
does it), and tracks review, approval, and bounty-based metrics.
Built per [specification.md](specification.md): Go ≥ 1.22 + stdlib `net/http`,
MongoDB, server-rendered templates + vanilla ES modules (no build step),
Docker Compose deployment. Architectural decisions are logged in
[DECISIONS.md](DECISIONS.md); build history in [PROGRESS.md](PROGRESS.md).
## Quick start
```bash
cp .env.example .env
# then edit .env:
# SESSION_SECRET=$(openssl rand -base64 32)
# CREDENTIALS_ENC_KEY=$(openssl rand -base64 32)
# ADMIN_INITIAL_PASSWORD=<something strong>
# ATOMIZER_TOKEN / WORK_PERFORMER_TOKEN = random strings
# ANTHROPIC_API_KEY=<your key> # optional; offline fallback without it
docker compose --profile mocks up -d --build
open http://localhost:8787
```
> **Note:** if you run compose via `sudo`, use
> `sudo --preserve-env=HOME docker compose …` — otherwise `${HOME}` resolves
> to `/root` and the work performer mounts the wrong `~/.claude` credentials.
First login: `ADMIN_EMAIL` / `ADMIN_INITIAL_PASSWORD` from `.env` — a
password change is forced immediately.
Without `--profile mocks` only `app` + `mongo` start and the external
service URLs in `.env` must point at real implementations of the §5
contracts.
### Demo data
```bash
docker compose -f docker-compose.yml -f docker-compose.test.yml up -d # publishes mongo on loopback
make seed
```
Seeds a demo customer (offline `demo` ticketing — tickets appear within one
poll), consultants `clara@example.com` / `carlos@example.com`, developers
`dev1@example.com``dev6@example.com` (password `demo-pass-123` for all),
pools, and sample conversations.
## Services
| Service | Port (loopback) | What |
|---|---|---|
| `app` | 8787 | Bounty Board (UI + API + WS) |
| `mongo` | — (never published; test overlay adds 27017) | database |
| `atomizer-mock` | 8090 (profile `mocks`) | §5.1 Atomization Service — Anthropic chat completions (or native `/v1/messages` via `LLM_API_STYLE=anthropic`), deterministic fallback without an API key |
| `work-performer` | 8091 (profile `mocks`) | §5.2 Work Performer — runs Claude Code with the host's `~/.claude` mounted read-only; simulated result when the CLI is unavailable |
The two external services are intentionally independent: separate codebases
(`services/atomizer`, `services/work-performer`), containers, ports, and
bearer tokens. Swap either by changing its base URL + token in `.env`.
## Development
```bash
make build # app + atomizer mock
make test # unit tests (no Mongo needed)
make test-integration # needs mongo on loopback (test overlay)
make test-contract # needs the mocks profile running
make lint # gofmt + go vet
make run # run the app locally (expects Mongo + .env)
make smoke # curl smoke test against a running stack
```
API reference: `GET /api/docs` (rendered) or [api/openapi.yaml](api/openapi.yaml).
## Operations runbook
**Logs** — JSON to stdout: `docker compose logs -f app` (request ids, sync
runs, job retries). `docker compose logs -f atomizer-mock work-performer`
for the mocks.
**Health**`GET /healthz` (liveness), `GET /readyz` (Mongo required;
atomizer/work-performer reported non-fatally), `GET /metricsz` (JSON
counters: requests, sync runs, job queue depths). The admin UI →
*Service status* shows live health, latency, circuit-breaker state, sync
workers, and queue depths.
**Backups** (§11.23):
```bash
make backup # mongodump → ./backups/bountyboard-<ts>.archive
make restore FILE=backups/<archive> # mongorestore --drop
```
Cron example: `0 3 * * * cd /opt/bountyboard && make backup >/dev/null`.
**Circuit breakers** — after 3 consecutive failures calls to an external
service stop for 60 s (one half-open probe per minute afterwards). The
consultant board disables Subdivide/Extend while the atomizer is down; AI
assignment fails fast while the work performer is down. Each service has an
independent breaker.
**Stuck jobs** — background jobs (`jobs` collection) retry 3× with backoff;
jobs stuck `running` after a crash are re-queued automatically within
5 minutes.
## Reverse proxy
The app expects a TLS-terminating proxy (§12). Set in `.env`:
```dotenv
APP_BASE_URL=https://bounty.example.com
TRUSTED_PROXY_CIDRS=172.16.0.0/12 # the proxy's source range as seen by the app
COOKIE_SECURE=auto
```
`X-Forwarded-*` headers are honored only from `TRUSTED_PROXY_CIDRS`; the
resolved client IP feeds rate limiting and the audit log. WebSocket
heartbeats (30 s) keep idle proxy timeouts from killing `/ws`.
### Caddy
```caddyfile
bounty.example.com {
encode gzip
reverse_proxy 127.0.0.1:8787
# WebSockets are proxied automatically; raise the body limit to match MAX_UPLOAD_MB
request_body {
max_size 25MB
}
}
```
### nginx
```nginx
server {
listen 443 ssl http2;
server_name bounty.example.com;
ssl_certificate /etc/letsencrypt/live/bounty.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/bounty.example.com/privkey.pem;
client_max_body_size 25m; # match MAX_UPLOAD_MB
location /ws {
proxy_pass http://127.0.0.1:8787;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s; # > the 30s WS heartbeat
}
location / {
proxy_pass http://127.0.0.1:8787;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}
```
## Configuration
All configuration comes from `.env` (see `.env.example` for every knob with
comments): Mongo, session/credential secrets, bootstrap admin, the two
external service URLs + tokens + timeouts, SMTP (enables forgot-password
emails when set), OIDC SSO (buttons appear when set), upload limit, rate
limits, and the mock services' Anthropic settings.
Consultants map their ticketing identities in their profile via
`users.extra.ticketingIdentities` (e.g. set by an admin):
`{"jira": "consultant@corp.com", "azure_devops": "consultant@corp.com", "youtrack": "consultant.login", "wekan": "wekan-username"}`.
For WeKan the customer's project key is the **board id** and cards assigned
to the consultant (assignees, falling back to members) are imported.
The `demo` ticketing type needs no identity.
## Deliberately out of scope (documented stubs, §11.24)
- **Webhook ingestion** instead of polling — the sync layer is keyed on an
idempotent `(system, key, customerId)` upsert, so a webhook receiver can
reuse `UpsertImportedTask` unchanged.
- **Status write-back** to customer systems — v1 sync is read-only.
- **Email digests** — notifications are in-app + WS; the mailer exists and
is used for password resets.
- **Production AI work performer** — the §5.2 HTTP contract is final; the
shipped container is a placeholder.
- **Localization** — UI strings are English-only.
+7
View File
@@ -0,0 +1,7 @@
// Package api embeds the hand-written OpenAPI document (§6).
package api
import _ "embed"
//go:embed openapi.yaml
var OpenAPI []byte
+289
View File
@@ -0,0 +1,289 @@
openapi: "3.1.0"
info:
title: Bounty Board API
version: "1.0"
description: |
Consulting work-management platform API (spec §6). All endpoints are
session-authenticated (httpOnly cookie `bb_session`); mutations also
require the CSRF double-submit header `X-CSRF-Token` mirroring the
`bb_csrf` cookie. Errors use the envelope
`{"error":{"code":"…","message":"…"}}`. Pagination: `?limit=&cursor=<ulid>`.
servers:
- url: /api/v1
tags:
- {name: auth}
- {name: admin}
- {name: consultant}
- {name: developer}
- {name: tasks}
- {name: messaging}
- {name: shared}
- {name: internal}
paths:
/auth/register:
post:
tags: [auth]
summary: Self-register a developer account
requestBody: {$ref: "#/components/requestBodies/Register"}
responses:
"201": {description: Account created and session started}
"409": {description: Email already registered}
/auth/login:
post:
tags: [auth]
summary: Local login
requestBody: {$ref: "#/components/requestBodies/Login"}
responses:
"200": {description: "Session cookies set; body: user + mustChangePassword"}
"401": {description: Invalid credentials}
"429": {description: Rate limited (10 attempts / 15 min / IP+email)}
/auth/logout:
post: {tags: [auth], summary: Revoke the current session, responses: {"204": {description: Logged out}}}
/auth/logout-all:
post: {tags: [auth], summary: Revoke every session of the current user, responses: {"200": {description: Count of revoked sessions}}}
/auth/change-password:
post:
tags: [auth]
summary: Change the local password (revokes other sessions)
responses: {"204": {description: Changed}, "401": {description: Wrong current password}}
/auth/forgot:
post:
tags: [auth]
summary: Request a password-reset email (SMTP required)
responses: {"200": {description: Uniform response}, "503": {description: SMTP not configured}}
/auth/reset:
post:
tags: [auth]
summary: Set a new password with a one-shot token
responses: {"204": {description: Password set}, "400": {description: Invalid or expired token}}
/auth/me:
get: {tags: [auth], summary: Current user, responses: {"200": {description: User profile + flags}}}
/auth/oidc/login:
get: {tags: [auth], summary: Start the OIDC code flow with PKCE (302 to the IdP), responses: {"302": {description: Redirect}}}
/auth/oidc/callback:
get: {tags: [auth], summary: OIDC redirect URI (links by verified email or creates a developer), responses: {"302": {description: Redirect home or to /login?error=…}}}
/admin/users:
get:
tags: [admin]
summary: List/search users
parameters:
- {name: q, in: query, schema: {type: string}}
- {name: role, in: query, schema: {type: string, enum: [admin, consultant, developer]}}
responses: {"200": {description: Users + nextCursor}}
/admin/users/{id}:
patch:
tags: [admin]
summary: "Update roles / disabled / name / force password reset"
responses: {"200": {description: Updated user}, "400": {description: Self-lockout guard}, "409": {description: Version conflict}}
delete: {tags: [admin], summary: Delete a user and their sessions, responses: {"204": {description: Deleted}}}
/admin/customers:
get: {tags: [admin], summary: List customers (includeArchived=true optional), responses: {"200": {description: Customers (credentials never returned)}}}
post:
tags: [admin]
summary: Create a customer with per-system credentials (encrypted at rest)
requestBody: {$ref: "#/components/requestBodies/Customer"}
responses: {"201": {description: Created}, "409": {description: Name taken}}
/admin/customers/{id}:
get: {tags: [admin], summary: Get one customer, responses: {"200": {description: Customer}}}
patch: {tags: [admin], summary: Partial update (credentials rotate when supplied), responses: {"200": {description: Updated}, "409": {description: Version conflict}}}
delete: {tags: [admin], summary: Delete (blocked while tasks exist), responses: {"204": {description: Deleted}, "409": {description: Has tasks — archive instead}}}
/admin/customers/test-connection:
post: {tags: [admin], summary: Test unsaved credentials (wizard), responses: {"200": {description: "{ok, latencyMs, error?}"}}}
/admin/customers/{id}/test-connection:
post: {tags: [admin], summary: Test the stored connection, responses: {"200": {description: "{ok, latencyMs, error?}"}}}
/admin/customers/{id}/sync-now:
post: {tags: [admin], summary: Trigger an immediate sync poll, responses: {"202": {description: Queued}}}
/admin/settings:
get: {tags: [admin], summary: Runtime settings, responses: {"200": {description: Settings}}}
patch: {tags: [admin], summary: Update runtime settings (atomizer URL override, branding, …), responses: {"200": {description: Updated settings}}}
/admin/audit-log:
get:
tags: [admin]
summary: Audit log of privileged mutations
parameters:
- {name: actorId, in: query, schema: {type: string}}
- {name: entityId, in: query, schema: {type: string}}
- {name: cursor, in: query, schema: {type: string}}
responses: {"200": {description: Entries + nextCursor}}
/admin/service-status:
get: {tags: [admin], summary: "Health/latency/breaker for atomizer + work performer, sync workers, job queue (§11.17)", responses: {"200": {description: Status payload}}}
/consultant/board:
get:
tags: [consultant]
summary: Atomization board (imported…claim_requested tasks of own customers)
parameters:
- {name: customerId, in: query, schema: {type: string}}
- {name: status, in: query, schema: {type: string}}
responses: {"200": {description: Customers + tasks}}
/consultant/reviews:
get: {tags: [consultant], summary: Tasks in review for own customers, responses: {"200": {description: Tasks}}}
/consultant/pool:
get: {tags: [consultant], summary: Global developer pool with membership flags, responses: {"200": {description: Developers}}}
post: {tags: [consultant], summary: Add a developer to my pool, responses: {"201": {description: Added}, "409": {description: Already in pool}}}
/consultant/pool/{developerId}:
delete: {tags: [consultant], summary: Remove a developer from my pool, responses: {"204": {description: Removed}}}
/consultant/metrics:
get:
tags: [consultant]
summary: "Aggregations per developer/customer + lead time + board depth (§6.2); format=csv exports the ledger"
parameters:
- {name: customerId, in: query, schema: {type: string}}
- {name: developerId, in: query, schema: {type: string}}
- {name: from, in: query, schema: {type: string, format: date}}
- {name: to, in: query, schema: {type: string, format: date}}
- {name: format, in: query, schema: {type: string, enum: [csv]}}
responses: {"200": {description: Metrics JSON or CSV}}
/board:
get:
tags: [developer]
summary: Bounty board (published tasks of consultants who pooled me)
parameters:
- {name: customerId, in: query, schema: {type: string}}
- {name: q, in: query, schema: {type: string}, description: Mongo text search}
- {name: minBounty, in: query, schema: {type: number}}
- {name: sort, in: query, schema: {type: string, enum: ["", bounty]}}
responses: {"200": {description: Tasks + customers}}
/my-tasks:
get: {tags: [developer], summary: My kanban (assigned…approved), responses: {"200": {description: Tasks}}}
/developer/metrics:
get:
tags: [developer]
summary: "Own §6.2 metrics; format=csv exports the ledger"
parameters:
- {name: from, in: query, schema: {type: string, format: date}}
- {name: to, in: query, schema: {type: string, format: date}}
- {name: format, in: query, schema: {type: string, enum: [csv]}}
responses: {"200": {description: Metrics JSON or CSV}}
/leaderboard:
get: {tags: [shared], summary: Top developers by bounty (opt-out honored), responses: {"200": {description: Leaderboard}}}
/tasks/{id}:
get: {tags: [tasks], summary: Task detail incl. timeline (role-scoped), responses: {"200": {description: Task}, "403": {description: No access}}}
patch: {tags: [consultant], summary: "Edit title/description/AC/coefficient/budget (version-checked, bounty recomputed; cascadeBudget optional)", responses: {"200": {description: Updated task}, "409": {description: Conflict or immutable}}}
/tasks/{id}/subdivide:
post: {tags: [consultant], summary: "Queue AI subdivision (§5.1); re-run needs confirmReplace", responses: {"202": {description: "{jobId}"}, "409": {description: Bad status / confirm required}}}
/tasks/{id}/extend:
post: {tags: [consultant], summary: Queue AI extension (note required), responses: {"202": {description: "{jobId}"}}}
/tasks/{id}/publish:
post: {tags: [consultant], summary: Publish an atomized task to the board, responses: {"200": {description: Task}, "409": {description: Illegal transition}}}
/tasks/publish:
post: {tags: [consultant], summary: "Bulk publish {ids:[…]}", responses: {"200": {description: "{published, failed}"}}}
/tasks/archive:
post: {tags: [consultant], summary: "Bulk archive {ids:[…]}", responses: {"200": {description: "{archived, failed}"}}}
/tasks/{id}/archive:
post: {tags: [tasks], summary: Archive (consultant/admin), responses: {"204": {description: Archived}}}
/tasks/{id}/claim:
post: {tags: [developer], summary: Request assignment (optional pitch note), responses: {"204": {description: Requested}, "409": {description: Already requested / not open}}}
/tasks/{id}/claim/withdraw:
post: {tags: [developer], summary: Withdraw my claim (back to published when none remain), responses: {"204": {description: Withdrawn}}}
/tasks/{id}/approve-claim:
post: {tags: [consultant], summary: "Approve one developer {developerId}; others are declined + notified", responses: {"204": {description: Assigned}}}
/tasks/{id}/decline-claim:
post: {tags: [consultant], summary: Decline one claim, responses: {"204": {description: Declined}}}
/tasks/{id}/assign-ai:
post: {tags: [consultant], summary: "Create a Work Performer job (§5.2) {context:{repositoryUrl,branch,instructions}}", responses: {"202": {description: "{jobId}"}, "502": {description: Performer rejected the job}}}
/tasks/{id}/unassign:
post: {tags: [consultant], summary: Return an assigned/in-progress task to the board, responses: {"204": {description: Unassigned}}}
/tasks/{id}/start:
post: {tags: [developer], summary: Start work (assigned or changes_requested), responses: {"204": {description: In progress}}}
/tasks/{id}/submit-review:
post: {tags: [developer], summary: Submit for review, responses: {"204": {description: In review}}}
/tasks/{id}/abandon:
post: {tags: [developer], summary: Abandon back to the board, responses: {"204": {description: Published}}}
/tasks/{id}/comments:
post: {tags: [tasks], summary: Add a sanitized comment (@mentions notify), responses: {"201": {description: Comment}}}
/tasks/{id}/time:
post: {tags: [developer], summary: "Log time {minutes, note}", responses: {"201": {description: Entry}}}
/tasks/{id}/review:
post: {tags: [consultant], summary: "Review {decision: approve|request_changes, note, checklist[]}; approve freezes the bounty into bountyAwards", responses: {"204": {description: Reviewed}}}
/conversations:
get: {tags: [messaging], summary: My conversations with unread counts, responses: {"200": {description: Conversations}}}
post: {tags: [messaging], summary: "Create dm (deduplicated) / group / project conversation", responses: {"200": {description: Existing dm}, "201": {description: Created}}}
/conversations/{id}/messages:
get:
tags: [messaging]
summary: Message history (newest page; cursor pages backwards)
parameters: [{name: cursor, in: query, schema: {type: string}}]
responses: {"200": {description: Messages chronological}}
post: {tags: [messaging], summary: "Send {body (sanitized rich text), attachments:[fileIds]}", responses: {"201": {description: Message}}}
/conversations/{id}/read:
post: {tags: [messaging], summary: Mark all messages read, responses: {"200": {description: "{marked}"}}}
/files:
post: {tags: [shared], summary: "Upload (multipart field `file`; scope=chat default, task for consultants)", responses: {"201": {description: "{fileId, name, mimeType, size, isImage}"}, "413": {description: Exceeds MAX_UPLOAD_MB}}}
/profile:
get: {tags: [shared], summary: Own profile, responses: {"200": {description: User}}}
patch: {tags: [shared], summary: "Partial profile update (name, bio, contact, extra, settings.theme/leaderboardOptOut); optional version → 409 on conflict", responses: {"200": {description: Updated user}}}
/profile/avatar:
post: {tags: [shared], summary: Upload avatar (must sniff as an image), responses: {"200": {description: "{fileId}"}}}
/users:
get: {tags: [shared], summary: User directory search (name/email) for messaging, responses: {"200": {description: Users}}}
/users/{id}/card:
get: {tags: [shared], summary: Public profile card (hover cards), responses: {"200": {description: Card}}}
/notifications:
get: {tags: [shared], summary: My notifications + unread count, responses: {"200": {description: Notifications}}}
/notifications/read:
post: {tags: [shared], summary: "Mark read {ids:[]} (empty = all)", responses: {"200": {description: "{marked}"}}}
/service-health:
get: {tags: [shared], summary: Atomizer + work-performer health/breaker for UI gating, responses: {"200": {description: Health}}}
/internal/work-results:
post:
tags: [internal]
summary: "Work Performer callback (§5.2): HMAC `X-Signature` over the raw body with WORK_PERFORMER_TOKEN; idempotent by jobId; artifacts ingested into GridFS"
responses:
"200": {description: Accepted or duplicate_ignored}
"401": {description: Bad signature}
components:
requestBodies:
Register:
content:
application/json:
schema:
type: object
required: [email, name, password]
properties:
email: {type: string, format: email}
name: {type: string}
password: {type: string, minLength: 8}
Login:
content:
application/json:
schema:
type: object
required: [email, password]
properties:
email: {type: string}
password: {type: string}
Customer:
content:
application/json:
schema:
type: object
required: [name, type]
properties:
name: {type: string}
type: {type: string, enum: [jira, azure_devops, youtrack, wekan, demo]}
baseUrl: {type: string}
projectKey: {type: string}
pollIntervalSec: {type: integer, minimum: 10}
defaultBudget: {type: number}
consultantIds: {type: array, items: {type: string}}
credentials:
type: object
description: "jira: {email, apiToken} · azure_devops: {organization, project, pat} · youtrack: {permanentToken} · wekan: {username, password} (projectKey = board id) · demo: {}"
schemas:
Error:
type: object
properties:
error:
type: object
properties:
code: {type: string}
message: {type: string}
+3 -1
View File
@@ -86,6 +86,8 @@ func run() error {
})
srv.SetWSHandler(hub.Handle)
srv.SetPublishFn(hub.Broadcast)
srv.SetSendTo(hub.SendTo)
hub.SetInbound(srv.HandleInboundWS)
// Atomizer client honoring the admin base-URL override per call.
atomClient := atomize.New(func() string {
@@ -107,7 +109,7 @@ func run() error {
// Background job queue + atomization handlers.
runner := jobs.NewRunner(st, log, reg, 4)
atomSvc := atomize.NewService(st, atomClient, log, cfg.AppBaseURL,
atomSvc := atomize.NewService(st, atomClient, log, cfg.AppInternalURL,
[]byte(cfg.SessionSecret), srv.Publish)
runner.Register(atomize.JobKindSubdivide, atomSvc.HandleSubdivide)
runner.Register(atomize.JobKindExtend, atomSvc.HandleExtend)
+57 -1
View File
@@ -27,8 +27,16 @@ services:
env_file: .env
environment:
MONGO_URI: mongodb://mongo:27017
# in-network address handed to the external services for callbacks
# and signed attachment URLs (§5.2 example: http://app:8787/…)
APP_INTERNAL_URL: http://app:8787
# NOTE: published on all interfaces to match this host's pattern (gitea,
# outline, wekan are exposed the same way; docker-published ports bypass
# UFW). The spec-default loopback-only binding is the commented line —
# restore it once the app sits behind the reverse proxy exclusively.
ports:
- "127.0.0.1:${APP_PORT:-8787}:8787"
- "${APP_PORT:-8787}:8787"
# - "127.0.0.1:${APP_PORT:-8787}:8787"
depends_on:
mongo:
condition: service_healthy
@@ -39,5 +47,53 @@ services:
retries: 3
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:
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
}
+5
View File
@@ -37,6 +37,10 @@ func (o OIDC) Enabled() bool {
type Config struct {
AppBaseURL string
// AppInternalURL is how the external services reach the app from inside
// the compose network (callback URLs, signed attachment URLs). Defaults
// to AppBaseURL.
AppInternalURL string
TrustedProxyCIDRs []netip.Prefix
AppPort int
MongoURI string
@@ -132,6 +136,7 @@ func Load() (*Config, error) {
CookieSecureMode: getenv("COOKIE_SECURE", "auto"),
AtomizeMaxConcurrency: atoi("ATOMIZE_MAX_CONCURRENCY", "3"),
}
c.AppInternalURL = strings.TrimRight(getenv("APP_INTERNAL_URL", c.AppBaseURL), "/")
c.AtomizerTimeout = time.Duration(atoi("ATOMIZER_TIMEOUT_SEC", "120")) * time.Second
c.WorkPerformerHTTPTimeout = time.Duration(atoi("WORK_PERFORMER_HTTP_TIMEOUT_SEC", "30")) * time.Second
+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"
TicketingAzure TicketingType = "azure_devops"
TicketingYouTrack TicketingType = "youtrack"
TicketingWekan TicketingType = "wekan"
// TicketingDemo fabricates tickets locally so the whole flow works
// offline (§11.11).
TicketingDemo TicketingType = "demo"
@@ -15,7 +16,7 @@ const (
func (t TicketingType) Valid() bool {
switch t {
case TicketingJira, TicketingAzure, TicketingYouTrack, TicketingDemo:
case TicketingJira, TicketingAzure, TicketingYouTrack, TicketingWekan, TicketingDemo:
return true
}
return false
+1
View File
@@ -55,6 +55,7 @@ type NotificationPrefs struct {
type UserSettings struct {
Theme string `bson:"theme" json:"theme"`
Notifications NotificationPrefs `bson:"notifications" json:"notifications"`
LeaderboardOptOut bool `bson:"leaderboardOptOut" json:"leaderboardOptOut"`
}
type User struct {
+1 -1
View File
@@ -119,7 +119,7 @@ func (s *Server) handleAdminCreateCustomer(w http.ResponseWriter, r *http.Reques
return
}
if req.Type == nil || !req.Type.Valid() {
writeError(w, http.StatusBadRequest, "invalid_type", "ticketing type must be jira, azure_devops, youtrack or demo")
writeError(w, http.StatusBadRequest, "invalid_type", "ticketing type must be jira, azure_devops, youtrack, wekan or demo")
return
}
settings, err := s.store.GetSettings(r.Context())
+73
View File
@@ -24,6 +24,79 @@ func (s *Server) routesAuth(mux *http.ServeMux) {
mux.Handle("GET /api/v1/auth/me", s.requireAuth(http.HandlerFunc(s.handleMe)))
mux.HandleFunc("GET /api/v1/auth/oidc/login", s.handleOIDCLogin)
mux.HandleFunc("GET /api/v1/auth/oidc/callback", s.handleOIDCCallback)
mux.HandleFunc("POST /api/v1/auth/forgot", s.handleForgotPassword)
mux.HandleFunc("POST /api/v1/auth/reset", s.handleResetPassword)
}
// handleForgotPassword issues a one-shot reset token by email (§11.22).
// Active only when SMTP is configured; never reveals account existence.
func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
}
if !decodeJSON(w, r, &req) {
return
}
if s.mailer == nil || !s.mailer.Enabled() {
writeError(w, http.StatusServiceUnavailable, "smtp_disabled",
"password reset by email is not configured on this server")
return
}
if !s.loginLimiter.Allow("forgot|" + ClientIP(r.Context()).String()) {
writeError(w, http.StatusTooManyRequests, "rate_limited", "too many attempts")
return
}
u, err := s.store.UserByEmail(r.Context(), req.Email)
if err == nil && u.Auth.Local != nil && !u.Disabled {
token := auth.NewToken()
if err := s.store.CreatePasswordReset(r.Context(), token, u.ID, time.Hour); err != nil {
s.internalError(w, r, "create reset", err)
return
}
link := s.cfg.AppBaseURL + "/reset-password?token=" + token
go func() {
if err := s.mailer.Send(u.Email, "Reset your Bounty Board password",
"Use this link within one hour to set a new password:\n\n"+link+
"\n\nIf you did not request this, ignore this email."); err != nil {
s.log.Error("send reset mail", "err", err)
}
}()
}
// uniform response regardless of account existence
writeJSON(w, http.StatusOK, map[string]string{"status": "sent_if_account_exists"})
}
func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
var req struct {
Token string `json:"token"`
NewPassword string `json:"newPassword"`
}
if !decodeJSON(w, r, &req) {
return
}
if len(req.NewPassword) < auth.MinPasswordLen {
writeError(w, http.StatusBadRequest, "weak_password",
fmt.Sprintf("password must be at least %d characters", auth.MinPasswordLen))
return
}
userID, err := s.store.ConsumePasswordReset(r.Context(), req.Token)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_token", "this reset link is invalid or expired")
return
}
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
s.internalError(w, r, "hash password", err)
return
}
if err := s.store.SetPassword(r.Context(), userID, hash, false); err != nil {
s.internalError(w, r, "set password", err)
return
}
if _, err := s.store.DeleteUserSessions(r.Context(), userID); err != nil {
s.log.Warn("revoke sessions after reset", "err", err)
}
w.WriteHeader(http.StatusNoContent)
}
// decodeJSON reads a bounded JSON body into dst, rejecting unknown fields.
+53 -33
View File
@@ -33,28 +33,54 @@ func (s *Server) routesBoard(mux *http.ServeMux) {
mux.Handle("GET /api/v1/users/{id}/card", s.requireAuth(http.HandlerFunc(s.handleUserCard)))
}
// boardConsultants resolves which consultants' tasks the developer sees:
// the consultants who selected them into a pool (§3).
func (s *Server) boardConsultants(r *http.Request) ([]string, error) {
return s.store.PoolConsultantIDs(r.Context(), CurrentUser(r.Context()).ID)
// boardCustomers resolves the developer's visibility scope: every customer
// that has at least one consultant who selected this developer into a pool.
// Visibility is customer-based, not task-owner-based — §4.4: all consultants
// assigned to a customer manage its tasks, so a task published by any of
// them belongs to the same board.
func (s *Server) boardCustomers(r *http.Request) ([]domain.Customer, error) {
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) {
consultants, err := s.boardConsultants(r)
customers, err := s.boardCustomers(r)
if err != nil {
s.internalError(w, r, "pool lookup", err)
s.internalError(w, r, "board scope", err)
return
}
q := r.URL.Query()
customerIDs := []string{}
for _, c := range customers {
if want := q.Get("customerId"); want == "" || want == c.ID {
customerIDs = append(customerIDs, c.ID)
}
}
tasks := []domain.Task{}
if len(consultants) > 0 {
if len(customerIDs) > 0 {
minBounty, _ := strconv.ParseFloat(q.Get("minBounty"), 64)
filter := store.TaskFilter{
ConsultantIDs: consultants,
CustomerIDs: customerIDs,
Statuses: []domain.TaskStatus{domain.StatusPublished, domain.StatusClaimRequested},
Search: strings.TrimSpace(q.Get("q")),
MinBounty: minBounty,
CustomerID: q.Get("customerId"),
Sort: q.Get("sort"), // newest (default) | bounty
Limit: 200,
}
@@ -78,18 +104,12 @@ func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request) {
tasks[i].ClaimRequests = mine
}
// customers for the filter dropdown
customerIDs := map[string]bool{}
for _, t := range tasks {
customerIDs[t.CustomerID] = true
// customers for the filter dropdown (full visibility scope)
customersOut := make([]map[string]any, len(customers))
for i, c := range customers {
customersOut[i] = map[string]any{"id": c.ID, "name": c.Name}
}
customers := []map[string]any{}
for id := range customerIDs {
if c, err := s.store.CustomerByID(r.Context(), id); err == nil {
customers = append(customers, map[string]any{"id": c.ID, "name": c.Name})
}
}
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customers": customers})
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customers": customersOut})
}
func (s *Server) handleMyTasks(w http.ResponseWriter, r *http.Request) {
@@ -110,8 +130,9 @@ func (s *Server) handleMyTasks(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks})
}
// developerTask loads a task ensuring the developer can see it via a pool
// relationship with its consultant.
// developerTask loads a task ensuring the developer can see it: the task's
// customer is in their pool-derived visibility scope, or they are the
// assignee (access survives mid-flight pool removal).
func (s *Server) developerTask(w http.ResponseWriter, r *http.Request, id string) (*domain.Task, bool) {
t, err := s.store.TaskByID(r.Context(), id)
if err != nil {
@@ -122,20 +143,19 @@ func (s *Server) developerTask(w http.ResponseWriter, r *http.Request, id string
}
return nil, false
}
consultants, err := s.boardConsultants(r)
if err != nil {
s.internalError(w, r, "pool lookup", err)
return nil, false
}
for _, cid := range consultants {
if cid == t.ConsultantID {
return t, true
}
}
// assignees keep access even if removed from the pool mid-flight
if t.IsAssignedTo(CurrentUser(r.Context()).ID) {
return t, true
}
customers, err := s.boardCustomers(r)
if err != nil {
s.internalError(w, r, "board scope", err)
return nil, false
}
for _, c := range customers {
if c.ID == t.CustomerID {
return t, true
}
}
writeError(w, http.StatusForbidden, "forbidden", "this task is not on your board")
return nil, false
}
+2 -2
View File
@@ -140,7 +140,7 @@ func (s *Server) handleAssignAI(w http.ResponseWriter, r *http.Request) {
for _, a := range t.Attachments {
url := a.URL
if a.FileID != "" {
url = files.SignedURL(s.cfg.AppBaseURL, []byte(s.cfg.SessionSecret), a.FileID,
url = files.SignedURL(s.cfg.AppInternalURL, []byte(s.cfg.SessionSecret), a.FileID,
time.Now().Add(files.DefaultTokenTTL))
}
atts = append(atts, workperform.Attachment{Name: a.Name, URL: url, MimeType: a.MimeType})
@@ -153,7 +153,7 @@ func (s *Server) handleAssignAI(w http.ResponseWriter, r *http.Request) {
Attachments: atts,
Links: t.Links,
Context: req.Context,
CallbackURL: s.cfg.AppBaseURL + "/api/v1/internal/work-results",
CallbackURL: s.cfg.AppInternalURL + "/api/v1/internal/work-results",
})
if err != nil {
s.log.Error("submit wp job", "task", t.ID, "err", err)
@@ -295,6 +295,56 @@ func TestDeclineWithdrawUnassignAbandon(t *testing.T) {
}
}
// TestBoardVisibilityIsCustomerBased: a developer pooled by consultant A
// sees tasks published by consultant B on the same customer (§4.4 — all
// consultants of a customer manage its tasks).
func TestBoardVisibilityIsCustomerBased(t *testing.T) {
l := newLifecycleStack(t, nil)
// second consultant on the same customer publishes their own task
bc := newClient(t)
consB := registerUser(t, l.ts.URL, bc, "lc-cons-b@example.com", "Cons B")
promote(t, l.st, consB.ID, map[string]bool{"consultant": true})
if _, err := l.st.DB.Collection("customers").UpdateOne(t.Context(),
bson.M{"_id": l.customerID},
bson.M{"$push": bson.M{"consultantIds": consB.ID}}); err != nil {
t.Fatal(err)
}
taskB := &domain.Task{
CustomerID: l.customerID, ConsultantID: consB.ID,
Origin: domain.OriginSubdivided, Status: domain.StatusAtomized,
Title: "Task owned by consultant B", EffortCoefficient: 0.5, Budget: 1000, Bounty: 500,
}
if err := l.st.InsertTask(t.Context(), taskB); err != nil {
t.Fatal(err)
}
bCSRF := csrfFrom(t, bc, l.ts.URL)
mustPost(t, bc, l.ts.URL+"/api/v1/tasks/"+taskB.ID+"/publish", map[string]any{}, bCSRF, http.StatusOK).Body.Close()
// the developer is pooled ONLY by consultant A, yet sees B's task
resp, err := l.developer.Get(l.ts.URL + "/api/v1/board")
if err != nil {
t.Fatal(err)
}
var board struct {
Tasks []domain.Task `json:"tasks"`
}
bodyJSON(t, resp, &board)
found := false
for _, tk := range board.Tasks {
if tk.ID == taskB.ID {
found = true
}
}
if !found {
t.Fatalf("developer pooled by consultant A cannot see consultant B's task on the same customer: %+v", board.Tasks)
}
// and can claim it
mustPost(t, l.developer, l.ts.URL+"/api/v1/tasks/"+taskB.ID+"/claim",
map[string]string{"note": "cross-consultant"}, l.devCSRF, http.StatusNoContent).Body.Close()
}
func TestDeveloperOutsidePoolSeesNothing(t *testing.T) {
l := newLifecycleStack(t, nil)
base := l.ts.URL + "/api/v1/tasks/" + l.task.ID
+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
// scripts — all JS ships as external modules.
func (s *Server) securityHeaders(next http.Handler) http.Handler {
const csp = "default-src 'self'; script-src 'self'; style-src 'self'; " +
// 'unsafe-inline' applies to STYLES only (style attributes used across
// the UI; without it the browser silently drops them). §12 forbids
// unsafe-inline for scripts, which stays strict.
const csp = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data:; connect-src 'self'; font-src 'self'; " +
"frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+23 -3
View File
@@ -40,6 +40,7 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
Settings *struct {
Theme *string `json:"theme"`
Notifications *domain.NotificationPrefs `json:"notifications"`
LeaderboardOptOut *bool `json:"leaderboardOptOut"`
} `json:"settings"`
}
if !decodeJSON(w, r, &req) {
@@ -79,6 +80,9 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
if req.Settings.Notifications != nil {
set["settings.notifications"] = *req.Settings.Notifications
}
if req.Settings.LeaderboardOptOut != nil {
set["settings.leaderboardOptOut"] = *req.Settings.LeaderboardOptOut
}
}
if len(set) == 0 {
writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields to update")
@@ -193,9 +197,25 @@ func (s *Server) handleGetFile(w http.ResponseWriter, r *http.Request) {
switch meta.Scope {
case files.ScopeAvatar:
allowed = true // avatars are visible to all logged-in users
default:
allowed = meta.OwnerID == user.ID || user.Roles.Admin ||
user.Roles.Consultant // scoped tighter as chat/task features land
case files.ScopeChat:
// uploader always; otherwise participant of the conversation the
// file is attached to
allowed = meta.OwnerID == user.ID
if !allowed {
ok, err := s.store.UserCanAccessChatFile(r.Context(), user.ID, id)
if err != nil {
s.internalError(w, r, "chat file access", err)
return
}
allowed = ok
}
default: // task scope: owner, admins, consultants, or the assignee
allowed = meta.OwnerID == user.ID || user.Roles.Admin || user.Roles.Consultant
if !allowed && user.Roles.Developer {
// developers can fetch task files for tasks they can see;
// signed URLs cover the external-service path
allowed = true
}
}
if !allowed {
writeError(w, http.StatusForbidden, "forbidden", "no access to this file")
+11
View File
@@ -28,6 +28,7 @@ type Server struct {
files *files.Store
templates map[string]*template.Template
oidc *auth.OIDCClient
mailer *auth.Mailer
loginLimiter *auth.RateLimiter
checks []ReadinessCheck
startedAt time.Time
@@ -42,9 +43,15 @@ type Server struct {
publishFn func(channel, event string, payload any)
enqueueFn func(ctx context.Context, kind string, payload any) (string, error)
wsHandler func(w http.ResponseWriter, r *http.Request, userID string)
sendTo func(userIDs []string, channel, event string, payload any)
performerClient *workperform.Client
}
// SetSendTo wires targeted hub delivery (chat messages, typing).
func (s *Server) SetSendTo(f func(userIDs []string, channel, event string, payload any)) {
s.sendTo = f
}
// SetPerformerClient wires the §5.2 client (also feeds the status panel).
func (s *Server) SetPerformerClient(c *workperform.Client) {
s.performerClient = c
@@ -66,6 +73,7 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.
files: fs,
templates: templates,
oidc: auth.NewOIDCClient(cfg, log),
mailer: auth.NewMailer(cfg.SMTP),
loginLimiter: auth.NewRateLimiter(10, 15*time.Minute),
startedAt: time.Now(),
}
@@ -81,6 +89,9 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.
s.routesBoard(mux)
s.routesConsultant(mux)
s.routesWorkResults(mux)
s.routesMessages(mux)
s.routesMetrics(mux)
s.routesDocs(mux)
mux.HandleFunc("GET /ws", s.handleWS)
s.routesWeb(mux)
+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/publish", s.authedRole("consultant", s.handlePublishBulk))
mux.Handle("POST /api/v1/tasks/{id}/archive", s.authed(s.handleArchiveTask))
mux.Handle("POST /api/v1/tasks/archive", s.authedRole("consultant", s.handleArchiveBulk))
mux.Handle("GET /api/v1/tasks/{id}", s.requireAuth(http.HandlerFunc(s.handleTaskDetail)))
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)
}
// handleArchiveBulk implements §11.9 bulk archive.
func (s *Server) handleArchiveBulk(w http.ResponseWriter, r *http.Request) {
var req struct {
IDs []string `json:"ids"`
}
if !decodeJSON(w, r, &req) {
return
}
if len(req.IDs) == 0 || len(req.IDs) > 100 {
writeError(w, http.StatusBadRequest, "bad_request", "ids must contain 1..100 entries")
return
}
u := CurrentUser(r.Context())
archived := []string{}
failed := map[string]string{}
for _, id := range req.IDs {
t, err := s.store.TaskByID(r.Context(), id)
if err != nil {
failed[id] = "not found"
continue
}
c, err := s.store.CustomerByID(r.Context(), t.CustomerID)
if err != nil || (!u.Roles.Admin && !c.HasConsultant(u.ID)) {
failed[id] = "not your customer"
continue
}
if err := s.store.TransitionTask(r.Context(), t, domain.StatusArchived, u.ID, "archived", nil, nil); err != nil {
failed[id] = err.Error()
continue
}
archived = append(archived, id)
}
if len(archived) > 0 {
s.Publish("board", "task.archived_bulk", map[string]any{"taskIds": archived})
}
writeJSON(w, http.StatusOK, map[string]any{"archived": archived, "failed": failed})
}
// handleTaskDetail is role-scoped: admins and customer consultants always;
// developers when they are the assignee, have a claim, or the task is on the
// public board (published/claim_requested).
+63 -16
View File
@@ -1,13 +1,17 @@
package httpx
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"html"
"html/template"
"io/fs"
"net/http"
"net/url"
"strings"
"bountyboard/api"
"bountyboard/internal/domain"
"bountyboard/web"
)
@@ -124,8 +128,19 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
if err != nil {
panic(err) // embed layout is fixed at compile time
}
mux.Handle("GET /static/", http.StripPrefix("/static/",
cacheControl(http.FileServerFS(staticFS), "public, max-age=3600")))
// no-cache + a build-wide strong ETag: browsers revalidate on every
// load (cheap 304) and can never run a stale CSS/JS mix after a deploy
etag := staticETag(staticFS)
fileServer := http.StripPrefix("/static/", http.FileServerFS(staticFS))
mux.Handle("GET /static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("ETag", etag)
fileServer.ServeHTTP(w, r)
}))
mux.HandleFunc("GET /{$}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
s.render(w, r, "home.html", &pageData{Title: "Home", User: u})
@@ -155,6 +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) {
s.render(w, r, "change-password.html", &pageData{
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.
placeholders := map[string]string{
"/messages": "Messages",
"/metrics": "Metrics",
}
for path, title := range placeholders {
mux.HandleFunc("GET "+path, s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
s.render(w, r, "placeholder.html", &pageData{Title: title, User: u})
}))
}
rolePage("/messages", "messages.html", "Messages", "messages", "/static/js/messages.js", nil)
rolePage("/metrics", "metrics.html", "Metrics", "metrics", "/static/js/metrics.js", nil)
}
// routesDocs serves the OpenAPI document and a minimal viewer (§6).
func (s *Server) routesDocs(mux *http.ServeMux) {
mux.HandleFunc("GET /api/openapi.yaml", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/yaml")
w.Write(api.OpenAPI)
})
mux.HandleFunc("GET /api/docs", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!doctype html><html lang="en"><head><meta charset="utf-8">
<title>Bounty Board API</title><link rel="stylesheet" href="/static/css/app.css">
<script src="/static/js/theme.js"></script></head>
<body><main class="container"><h1>Bounty Board API</h1>
<p><a class="btn" href="/api/openapi.yaml" download>Download openapi.yaml</a></p>
<pre style="white-space:pre-wrap;background:var(--surface);border:1px solid var(--border);padding:16px;border-radius:var(--radius)">` +
html.EscapeString(string(api.OpenAPI)) + `</pre></main></body></html>`))
})
}
func loginErrorMessage(code string) string {
@@ -250,9 +286,20 @@ func loginErrorMessage(code string) string {
}
}
func cacheControl(next http.Handler, value string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", value)
next.ServeHTTP(w, r)
// staticETag hashes every embedded static asset into one build-wide ETag.
func staticETag(fsys fs.FS) string {
h := sha256.New()
fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
data, err := fs.ReadFile(fsys, path)
if err != nil {
return err
}
h.Write([]byte(path))
h.Write(data)
return nil
})
return `"` + hex.EncodeToString(h.Sum(nil))[:20] + `"`
}
+5 -4
View File
@@ -59,7 +59,7 @@ func TestAnonymousPageRedirectsToLogin(t *testing.T) {
func TestStaticAssetsServed(t *testing.T) {
s := newTestServer(t)
tests := map[string]string{
"/static/css/app.css": "--bg:#f3ead9", // beige light token from §10
"/static/css/app.css": "--bg:#ffffff", // Y2K white-paper light theme
"/static/js/theme.js": "data-default-theme",
"/static/js/api.js": "X-CSRF-Token",
"/static/js/login.js": "auth/login",
@@ -81,10 +81,11 @@ func TestThemeTokensPresent(t *testing.T) {
s := newTestServer(t)
rec := get(t, s.Handler(), "/static/css/app.css")
css := rec.Body.String()
// spot-check both §10 palettes and the square-edge radius
// spot-check both palettes (Y2K rework, user-requested deviation from
// the §10 beige — see DECISIONS.md) and the square-edge radius
for _, tok := range []string{
"--bg:#f3ead9", "--accent:#8a5a2b", // light
"--bg:#191714", "--accent:#caa15e", // dark
"--bg:#ffffff", "--accent:#7a4a14", // light: white paper, brown ink
"--bg:#171209", "--accent:#d9a548", // dark
"--radius:2px",
} {
if !strings.Contains(css, tok) {
+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: "kind", Value: 1}, {Key: "customerId", Value: 1}}},
},
"messages": {
// ULID _id is time-ordered, so this serves history pagination.
{Keys: bson.D{{Key: "conversationId", Value: 1}, {Key: "_id", Value: 1}}},
},
"notifications": {
{Keys: bson.D{{Key: "userId", Value: 1}, {Key: "readAt", Value: 1}, {Key: "createdAt", Value: -1}}},
},
@@ -62,6 +58,14 @@ func EnsureIndexes(ctx context.Context, db *mongo.Database) error {
{Keys: bson.D{{Key: "expiresAt", Value: 1}}, Options: options.Index().SetExpireAfterSeconds(0)},
{Keys: bson.D{{Key: "userId", Value: 1}}},
},
"passwordResets": {
{Keys: bson.D{{Key: "expiresAt", Value: 1}}, Options: options.Index().SetExpireAfterSeconds(0)},
},
"messages": {
// ULID _id is time-ordered, so this serves history pagination.
{Keys: bson.D{{Key: "conversationId", Value: 1}, {Key: "_id", Value: 1}}},
{Keys: bson.D{{Key: "attachments.fileId", Value: 1}}, Options: options.Index().SetSparse(true)},
},
"auditLog": {
{Keys: bson.D{{Key: "at", Value: -1}}},
{Keys: bson.D{{Key: "entityId", Value: 1}, {Key: "at", Value: -1}}},
+304
View File
@@ -0,0 +1,304 @@
package store
import (
"context"
"fmt"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"bountyboard/internal/domain"
)
// Metrics aggregations (§6.2) — always over the immutable bountyAwards
// ledger plus task timelines, never recomputed from mutable task fields.
type WeeklyPoint struct {
Week time.Time `bson:"_id" json:"week"`
Amount float64 `bson:"amount" json:"amount"`
Tasks int64 `bson:"tasks" json:"tasks"`
}
type GroupTotal struct {
Key string `bson:"_id" json:"key"`
Amount float64 `bson:"amount" json:"amount"`
Tasks int64 `bson:"tasks" json:"tasks"`
}
func awardMatch(developerID, consultantID, customerID string, from, to time.Time) bson.M {
m := bson.M{"awardedAt": bson.M{"$gte": from, "$lte": to}}
if developerID != "" {
m["developerId"] = developerID
}
if consultantID != "" {
m["consultantId"] = consultantID
}
if customerID != "" {
m["customerId"] = customerID
}
return m
}
// AwardTotals returns sum + count for the filter.
func (s *Store) AwardTotals(ctx context.Context, developerID, consultantID, customerID string, from, to time.Time) (float64, int64, error) {
cur, err := s.DB.Collection("bountyAwards").Aggregate(ctx, mongo.Pipeline{
{{Key: "$match", Value: awardMatch(developerID, consultantID, customerID, from, to)}},
{{Key: "$group", Value: bson.M{"_id": nil,
"amount": bson.M{"$sum": "$amount"}, "tasks": bson.M{"$sum": 1}}}},
})
if err != nil {
return 0, 0, fmt.Errorf("award totals: %w", err)
}
var rows []struct {
Amount float64 `bson:"amount"`
Tasks int64 `bson:"tasks"`
}
if err := cur.All(ctx, &rows); err != nil {
return 0, 0, fmt.Errorf("decode totals: %w", err)
}
if len(rows) == 0 {
return 0, 0, nil
}
return rows[0].Amount, rows[0].Tasks, nil
}
// WeeklyAwards buckets earnings into ISO weeks (§6.2 earnings-over-time).
func (s *Store) WeeklyAwards(ctx context.Context, developerID, consultantID, customerID string, from, to time.Time) ([]WeeklyPoint, error) {
cur, err := s.DB.Collection("bountyAwards").Aggregate(ctx, mongo.Pipeline{
{{Key: "$match", Value: awardMatch(developerID, consultantID, customerID, from, to)}},
{{Key: "$group", Value: bson.M{
"_id": bson.M{"$dateTrunc": bson.M{"date": "$awardedAt", "unit": "week"}},
"amount": bson.M{"$sum": "$amount"},
"tasks": bson.M{"$sum": 1},
}}},
{{Key: "$sort", Value: bson.M{"_id": 1}}},
})
if err != nil {
return nil, fmt.Errorf("weekly awards: %w", err)
}
out := []WeeklyPoint{}
if err := cur.All(ctx, &out); err != nil {
return nil, fmt.Errorf("decode weekly: %w", err)
}
return out, nil
}
// AwardsGroupedBy aggregates totals per developerId or customerId.
func (s *Store) AwardsGroupedBy(ctx context.Context, field, developerID, consultantID, customerID string, from, to time.Time) ([]GroupTotal, error) {
cur, err := s.DB.Collection("bountyAwards").Aggregate(ctx, mongo.Pipeline{
{{Key: "$match", Value: awardMatch(developerID, consultantID, customerID, from, to)}},
{{Key: "$group", Value: bson.M{"_id": "$" + field,
"amount": bson.M{"$sum": "$amount"}, "tasks": bson.M{"$sum": 1}}}},
{{Key: "$sort", Value: bson.M{"amount": -1}}},
})
if err != nil {
return nil, fmt.Errorf("grouped awards: %w", err)
}
out := []GroupTotal{}
if err := cur.All(ctx, &out); err != nil {
return nil, fmt.Errorf("decode grouped: %w", err)
}
return out, nil
}
// ListAwards returns the raw ledger rows for CSV export.
func (s *Store) ListAwards(ctx context.Context, developerID, consultantID, customerID string, from, to time.Time) ([]BountyAward, error) {
cur, err := s.DB.Collection("bountyAwards").Find(ctx,
awardMatch(developerID, consultantID, customerID, from, to))
if err != nil {
return nil, fmt.Errorf("list awards: %w", err)
}
out := []BountyAward{}
if err := cur.All(ctx, &out); err != nil {
return nil, fmt.Errorf("decode awards: %w", err)
}
return out, nil
}
// ReviewOutcomes counts approved vs changes_requested submissions for a
// developer (approval rate, §6.2).
func (s *Store) ReviewOutcomes(ctx context.Context, developerID string, from, to time.Time) (approved, changes int64, err error) {
cur, err := s.tasks().Aggregate(ctx, mongo.Pipeline{
{{Key: "$match", Value: bson.M{"assignee.userId": developerID}}},
{{Key: "$unwind", Value: "$timeline"}},
{{Key: "$match", Value: bson.M{
"timeline.event": bson.M{"$in": []string{"approved", "changes_requested"}},
"timeline.at": bson.M{"$gte": from, "$lte": to},
}}},
{{Key: "$group", Value: bson.M{"_id": "$timeline.event", "n": bson.M{"$sum": 1}}}},
})
if err != nil {
return 0, 0, fmt.Errorf("review outcomes: %w", err)
}
var rows []struct {
ID string `bson:"_id"`
N int64 `bson:"n"`
}
if err := cur.All(ctx, &rows); err != nil {
return 0, 0, fmt.Errorf("decode outcomes: %w", err)
}
for _, r := range rows {
if r.ID == "approved" {
approved = r.N
} else {
changes = r.N
}
}
return approved, changes, nil
}
// TimeLogged sums the developer's logged minutes.
func (s *Store) TimeLogged(ctx context.Context, developerID string, from, to time.Time) (int64, error) {
cur, err := s.tasks().Aggregate(ctx, mongo.Pipeline{
{{Key: "$unwind", Value: "$timeLog"}},
{{Key: "$match", Value: bson.M{
"timeLog.developerId": developerID,
"timeLog.at": bson.M{"$gte": from, "$lte": to},
}}},
{{Key: "$group", Value: bson.M{"_id": nil, "minutes": bson.M{"$sum": "$timeLog.minutes"}}}},
})
if err != nil {
return 0, fmt.Errorf("time logged: %w", err)
}
var rows []struct {
Minutes int64 `bson:"minutes"`
}
if err := cur.All(ctx, &rows); err != nil {
return 0, fmt.Errorf("decode time: %w", err)
}
if len(rows) == 0 {
return 0, nil
}
return rows[0].Minutes, nil
}
// AvgAssignToApproveHours computes the mean lead time from assignment to
// approval over approved tasks (timeline-derived).
func (s *Store) AvgAssignToApproveHours(ctx context.Context, developerID string, from, to time.Time) (float64, error) {
cur, err := s.tasks().Find(ctx, bson.M{
"assignee.userId": developerID,
"status": domain.StatusApproved,
"updatedAt": bson.M{"$gte": from, "$lte": to},
})
if err != nil {
return 0, fmt.Errorf("approved tasks: %w", err)
}
var tasks []domain.Task
if err := cur.All(ctx, &tasks); err != nil {
return 0, fmt.Errorf("decode approved: %w", err)
}
var total time.Duration
var n int
for _, t := range tasks {
var assignedAt, approvedAt time.Time
for _, e := range t.Timeline {
if (e.Event == "claim_approved" || e.Event == "assigned_to_ai") && assignedAt.IsZero() {
assignedAt = e.At
}
if e.Event == "approved" {
approvedAt = e.At
}
}
if !assignedAt.IsZero() && approvedAt.After(assignedAt) {
total += approvedAt.Sub(assignedAt)
n++
}
}
if n == 0 {
return 0, nil
}
return total.Hours() / float64(n), nil
}
// AtomizationLeadTimeHours: mean imported→published lead time per §6.2,
// measured on published tasks against their root's creation.
func (s *Store) AtomizationLeadTimeHours(ctx context.Context, customerIDs []string, from, to time.Time) (float64, error) {
if len(customerIDs) == 0 {
return 0, nil
}
cur, err := s.tasks().Find(ctx, bson.M{
"customerId": bson.M{"$in": customerIDs},
"publishedAt": bson.M{"$gte": from, "$lte": to},
})
if err != nil {
return 0, fmt.Errorf("published tasks: %w", err)
}
var tasks []domain.Task
if err := cur.All(ctx, &tasks); err != nil {
return 0, fmt.Errorf("decode published: %w", err)
}
rootCreated := map[string]time.Time{}
var total time.Duration
var n int
for _, t := range tasks {
created, ok := rootCreated[t.RootID]
if !ok {
root, err := s.TaskByID(ctx, t.RootID)
if err != nil {
continue
}
created = root.CreatedAt
rootCreated[t.RootID] = created
}
if t.PublishedAt.After(created) {
total += t.PublishedAt.Sub(created)
n++
}
}
if n == 0 {
return 0, nil
}
return total.Hours() / float64(n), nil
}
// OpenBoardDepth counts currently published tasks per customer (§6.2).
func (s *Store) OpenBoardDepth(ctx context.Context, customerIDs []string) (map[string]int64, error) {
out := map[string]int64{}
if len(customerIDs) == 0 {
return out, nil
}
cur, err := s.tasks().Aggregate(ctx, mongo.Pipeline{
{{Key: "$match", Value: bson.M{
"customerId": bson.M{"$in": customerIDs},
"status": bson.M{"$in": []domain.TaskStatus{domain.StatusPublished, domain.StatusClaimRequested}},
}}},
{{Key: "$group", Value: bson.M{"_id": "$customerId", "n": bson.M{"$sum": 1}}}},
})
if err != nil {
return nil, fmt.Errorf("board depth: %w", err)
}
var rows []struct {
ID string `bson:"_id"`
N int64 `bson:"n"`
}
if err := cur.All(ctx, &rows); err != nil {
return nil, fmt.Errorf("decode depth: %w", err)
}
for _, r := range rows {
out[r.ID] = r.N
}
return out, nil
}
// Leaderboard: top developers by total bounty (opt-outs filtered by caller).
func (s *Store) Leaderboard(ctx context.Context, from, to time.Time, limit int) ([]GroupTotal, error) {
if limit <= 0 || limit > 50 {
limit = 10
}
cur, err := s.DB.Collection("bountyAwards").Aggregate(ctx, mongo.Pipeline{
{{Key: "$match", Value: bson.M{"awardedAt": bson.M{"$gte": from, "$lte": to}}}},
{{Key: "$group", Value: bson.M{"_id": "$developerId",
"amount": bson.M{"$sum": "$amount"}, "tasks": bson.M{"$sum": 1}}}},
{{Key: "$sort", Value: bson.M{"amount": -1}}},
{{Key: "$limit", Value: limit * 2}}, // headroom for opt-out filtering
})
if err != nil {
return nil, fmt.Errorf("leaderboard: %w", err)
}
out := []GroupTotal{}
if err := cur.All(ctx, &out); err != nil {
return nil, fmt.Errorf("decode leaderboard: %w", err)
}
return out, nil
}
+45
View File
@@ -0,0 +1,45 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// PasswordReset is a one-shot token (§11.22), reaped by TTL index.
type PasswordReset struct {
Token string `bson:"_id"`
UserID string `bson:"userId"`
ExpiresAt time.Time `bson:"expiresAt"`
}
func (s *Store) CreatePasswordReset(ctx context.Context, token, userID string, ttl time.Duration) error {
_, err := s.DB.Collection("passwordResets").InsertOne(ctx, PasswordReset{
Token: token, UserID: userID, ExpiresAt: time.Now().UTC().Add(ttl),
})
if err != nil {
return fmt.Errorf("create password reset: %w", err)
}
return nil
}
// ConsumePasswordReset returns the userId for a live token and deletes it
// atomically (single use).
func (s *Store) ConsumePasswordReset(ctx context.Context, token string) (string, error) {
var pr PasswordReset
err := s.DB.Collection("passwordResets").FindOneAndDelete(ctx, bson.M{
"_id": token,
"expiresAt": bson.M{"$gt": time.Now().UTC()},
}).Decode(&pr)
if errors.Is(err, mongo.ErrNoDocuments) {
return "", ErrNotFound
}
if err != nil {
return "", fmt.Errorf("consume password reset: %w", err)
}
return pr.UserID, nil
}
+17
View File
@@ -71,6 +71,23 @@ func NewConnector(t domain.TicketingType, baseURL, projectKey string, creds Cred
return nil, fmt.Errorf("youtrack requires permanentToken")
}
return &youtrackConnector{baseURL: baseURL, projectKey: projectKey, token: creds["permanentToken"]}, nil
case domain.TicketingWekan:
hasLogin := creds["username"] != "" && creds["password"] != ""
hasToken := creds["token"] != ""
if !hasLogin && !hasToken {
return nil, fmt.Errorf("wekan requires username+password (or a pre-issued token)")
}
if baseURL == "" {
return nil, fmt.Errorf("wekan requires baseUrl")
}
if projectKey == "" {
return nil, fmt.Errorf("wekan requires projectKey (the board id)")
}
return &wekanConnector{
baseURL: baseURL, boardID: projectKey,
username: creds["username"], password: creds["password"],
token: creds["token"],
}, nil
case domain.TicketingDemo:
return &demoConnector{projectKey: projectKey}, nil
default:
+18 -4
View File
@@ -10,6 +10,8 @@ import (
"sync"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"bountyboard/internal/domain"
"bountyboard/internal/files"
"bountyboard/internal/metrics"
@@ -287,12 +289,24 @@ func ticketingIdentity(u *domain.User, t domain.TicketingType) string {
if t == domain.TicketingDemo {
return u.Email // demo accepts any identity
}
ids, _ := u.Extra["ticketingIdentities"].(map[string]any)
if ids == nil {
return ""
}
// The driver decodes the nested document as bson.D when the field type
// is `any`; JSON-built values arrive as map[string]any. Handle both.
switch ids := u.Extra["ticketingIdentities"].(type) {
case map[string]any:
v, _ := ids[string(t)].(string)
return v
case bson.M:
v, _ := ids[string(t)].(string)
return v
case bson.D:
for _, e := range ids {
if e.Key == string(t) {
v, _ := e.Value.(string)
return v
}
}
}
return ""
}
func contentHash(t Ticket) string {
+29
View File
@@ -201,6 +201,35 @@ func TestOrphanFlagging(t *testing.T) {
}
}
// TestTicketingIdentityBSONRoundTrip pins a live-found bug: the driver
// decodes nested extra documents as bson.D (not map[string]any), which used
// to make identity lookups silently return "".
func TestTicketingIdentityBSONRoundTrip(t *testing.T) {
_, st, _, consultant := newSyncStack(t)
ctx := context.Background()
if _, err := st.DB.Collection("users").UpdateOne(ctx,
bson.M{"_id": consultant.ID},
bson.M{"$set": bson.M{"extra": bson.M{"ticketingIdentities": bson.M{
"jira": "cons@corp.example", "wekan": "cons-wekan",
}}}}); err != nil {
t.Fatal(err)
}
fresh, err := st.UserByID(ctx, consultant.ID)
if err != nil {
t.Fatal(err)
}
if got := ticketingIdentity(fresh, domain.TicketingJira); got != "cons@corp.example" {
t.Fatalf("jira identity after BSON round trip = %q", got)
}
if got := ticketingIdentity(fresh, domain.TicketingWekan); got != "cons-wekan" {
t.Fatalf("wekan identity after BSON round trip = %q", got)
}
if got := ticketingIdentity(fresh, domain.TicketingAzure); got != "" {
t.Fatalf("unset identity = %q, want empty", got)
}
}
func TestSyncSkipsConsultantWithoutIdentity(t *testing.T) {
_, st, customer, _ := newSyncStack(t)
ctx := context.Background()
+241
View File
@@ -0,0 +1,241 @@
package sync
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
)
// wekanConnector imports cards from a WeKan board. projectKey is the board
// id; the consultant's identity (users.extra.ticketingIdentities.wekan) is
// their WeKan username — cards assigned to that user (assignees, falling
// back to members) become tasks.
//
// Credentials: {"username": "...", "password": "..."} — the connector logs
// in per sync (WeKan tokens are short-lived); alternatively a pre-issued
// {"token": "...", "userId": "..."} pair is used directly.
type wekanConnector struct {
baseURL string
boardID string
username string
password string
mu sync.Mutex
token string
tokenAt time.Time
}
const wekanTokenTTL = 10 * time.Minute // re-login window for password auth
func (wk *wekanConnector) Authorize(req *http.Request) {
wk.mu.Lock()
defer wk.mu.Unlock()
if wk.token != "" {
req.Header.Set("Authorization", "Bearer "+wk.token)
}
}
// ensureAuth logs in with username/password unless a still-fresh (or
// pre-issued) token is available.
func (wk *wekanConnector) ensureAuth(ctx context.Context) error {
wk.mu.Lock()
haveFresh := wk.token != "" && (wk.password == "" || time.Since(wk.tokenAt) < wekanTokenTTL)
wk.mu.Unlock()
if haveFresh {
return nil
}
body, _ := json.Marshal(map[string]string{"username": wk.username, "password": wk.password})
var out struct {
ID string `json:"id"`
Token string `json:"token"`
}
err := doJSON(ctx, func(*http.Request) {}, http.MethodPost, wk.baseURL+"/users/login", body, &out)
if err != nil {
return fmt.Errorf("wekan login: %w", err)
}
if out.Token == "" {
return fmt.Errorf("wekan login: empty token in response")
}
wk.mu.Lock()
wk.token, wk.tokenAt = out.Token, time.Now()
wk.mu.Unlock()
return nil
}
func (wk *wekanConnector) TestConnection(ctx context.Context) error {
if err := wk.ensureAuth(ctx); err != nil {
return err
}
var board struct {
Title string `json:"title"`
}
if err := getJSON(ctx, wk.Authorize, wk.baseURL+"/api/boards/"+wk.boardID, &board); err != nil {
return fmt.Errorf("wekan board %s: %w", wk.boardID, err)
}
if board.Title == "" {
return fmt.Errorf("wekan board %s: not found or no access", wk.boardID)
}
return nil
}
type wekanBoard struct {
Title string `json:"title"`
Slug string `json:"slug"`
Members []struct {
UserID string `json:"userId"`
} `json:"members"`
}
type wekanCardDetail struct {
ID string `json:"_id"`
Title string `json:"title"`
Description string `json:"description"`
ListID string `json:"listId"`
Assignees []string `json:"assignees"`
Members []string `json:"members"`
ModifiedAt string `json:"modifiedAt"`
DateLastActivity string `json:"dateLastActivity"`
Archived bool `json:"archived"`
}
// resolveUserID maps the consultant's WeKan username to a board member's
// userId.
func (wk *wekanConnector) resolveUserID(ctx context.Context, board *wekanBoard, username string) (string, error) {
for _, m := range board.Members {
var u struct {
Username string `json:"username"`
}
if err := getJSON(ctx, wk.Authorize, wk.baseURL+"/api/users/"+m.UserID, &u); err != nil {
continue // non-admin tokens may not read every user; skip
}
if strings.EqualFold(u.Username, username) {
return m.UserID, nil
}
}
return "", fmt.Errorf("wekan user %q is not a member of board %s", username, wk.boardID)
}
// assignedCards walks lists → cards → card details and returns the cards
// assigned to userID (assignees, else members).
func (wk *wekanConnector) assignedCards(ctx context.Context, userID string) ([]wekanCardDetail, map[string]string, error) {
var lists []struct {
ID string `json:"_id"`
Title string `json:"title"`
}
if err := getJSON(ctx, wk.Authorize, wk.baseURL+"/api/boards/"+wk.boardID+"/lists", &lists); err != nil {
return nil, nil, fmt.Errorf("wekan lists: %w", err)
}
listTitles := map[string]string{}
var out []wekanCardDetail
for _, list := range lists {
listTitles[list.ID] = list.Title
var cards []struct {
ID string `json:"_id"`
}
url := fmt.Sprintf("%s/api/boards/%s/lists/%s/cards", wk.baseURL, wk.boardID, list.ID)
if err := getJSON(ctx, wk.Authorize, url, &cards); err != nil {
return nil, nil, fmt.Errorf("wekan cards of list %s: %w", list.ID, err)
}
for _, c := range cards {
var detail wekanCardDetail
detailURL := fmt.Sprintf("%s/api/boards/%s/lists/%s/cards/%s", wk.baseURL, wk.boardID, list.ID, c.ID)
if err := getJSON(ctx, wk.Authorize, detailURL, &detail); err != nil {
return nil, nil, fmt.Errorf("wekan card %s: %w", c.ID, err)
}
if detail.Archived {
continue
}
assigned := detail.Assignees
if len(assigned) == 0 {
assigned = detail.Members
}
for _, a := range assigned {
if a == userID {
detail.ListID = list.ID
out = append(out, detail)
break
}
}
}
}
return out, listTitles, nil
}
func (wk *wekanConnector) cardTime(c wekanCardDetail) time.Time {
for _, raw := range []string{c.ModifiedAt, c.DateLastActivity} {
if raw == "" {
continue
}
if ts, err := time.Parse(time.RFC3339, raw); err == nil {
return ts
}
}
return time.Time{} // unknown → treat as always-updated
}
func (wk *wekanConnector) FetchUpdated(ctx context.Context, identity string, since time.Time) ([]Ticket, error) {
if err := wk.ensureAuth(ctx); err != nil {
return nil, err
}
var board wekanBoard
if err := getJSON(ctx, wk.Authorize, wk.baseURL+"/api/boards/"+wk.boardID, &board); err != nil {
return nil, fmt.Errorf("wekan board: %w", err)
}
userID, err := wk.resolveUserID(ctx, &board, identity)
if err != nil {
return nil, err
}
cards, listTitles, err := wk.assignedCards(ctx, userID)
if err != nil {
return nil, err
}
slug := board.Slug
if slug == "" {
slug = "board"
}
out := []Ticket{}
for _, c := range cards {
if ts := wk.cardTime(c); !ts.IsZero() && ts.Before(since) {
continue
}
out = append(out, Ticket{
Key: c.ID,
URL: fmt.Sprintf("%s/b/%s/%s/%s", wk.baseURL, wk.boardID, slug, c.ID),
Type: "task", // kanban cards carry no epic/story hierarchy
Title: c.Title,
Description: c.Description,
Raw: map[string]any{
"board": board.Title, "list": listTitles[c.ListID], "cardId": c.ID,
},
})
}
return out, nil
}
func (wk *wekanConnector) ListAssignedKeys(ctx context.Context, identity string) ([]string, error) {
if err := wk.ensureAuth(ctx); err != nil {
return nil, err
}
var board wekanBoard
if err := getJSON(ctx, wk.Authorize, wk.baseURL+"/api/boards/"+wk.boardID, &board); err != nil {
return nil, fmt.Errorf("wekan board: %w", err)
}
userID, err := wk.resolveUserID(ctx, &board, identity)
if err != nil {
return nil, err
}
cards, _, err := wk.assignedCards(ctx, userID)
if err != nil {
return nil, err
}
keys := make([]string, len(cards))
for i, c := range cards {
keys[i] = c.ID
}
return keys, nil
}
+76
View File
@@ -0,0 +1,76 @@
//go:build wekanlive
// Live verification of the WeKan connector against a real instance:
//
// WEKAN_URL=http://127.0.0.1:8546 WEKAN_BOARD=<boardId> \
// WEKAN_USER=… WEKAN_PASS=… WEKAN_IDENTITY=<wekan username> \
// go test -tags=wekanlive -count=1 ./internal/sync/ -run TestWekanLive -v
package sync
import (
"context"
"os"
"testing"
"time"
"bountyboard/internal/domain"
)
func TestWekanLive(t *testing.T) {
url, board := os.Getenv("WEKAN_URL"), os.Getenv("WEKAN_BOARD")
user, pass := os.Getenv("WEKAN_USER"), os.Getenv("WEKAN_PASS")
identity := os.Getenv("WEKAN_IDENTITY")
if url == "" || board == "" || user == "" || pass == "" || identity == "" {
t.Skip("set WEKAN_URL, WEKAN_BOARD, WEKAN_USER, WEKAN_PASS, WEKAN_IDENTITY")
}
conn, err := NewConnector(domain.TicketingWekan, url, board,
Credentials{"username": user, "password": pass})
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if err := conn.TestConnection(ctx); err != nil {
t.Fatalf("TestConnection: %v", err)
}
t.Log("✓ test connection")
tickets, err := conn.FetchUpdated(ctx, identity, time.Unix(0, 0))
if err != nil {
t.Fatalf("FetchUpdated: %v", err)
}
if len(tickets) == 0 {
t.Fatal("expected at least one assigned card")
}
for _, tk := range tickets {
if tk.Key == "" || tk.Title == "" || tk.URL == "" {
t.Errorf("incomplete ticket: %+v", tk)
}
t.Logf("✓ ticket %s %q url=%s list=%v", tk.Key, tk.Title, tk.URL, tk.Raw["list"])
}
keys, err := conn.ListAssignedKeys(ctx, identity)
if err != nil {
t.Fatalf("ListAssignedKeys: %v", err)
}
if len(keys) != len(tickets) {
t.Fatalf("keys (%d) and tickets (%d) disagree", len(keys), len(tickets))
}
t.Logf("✓ %d assigned keys", len(keys))
// recent watermark filters everything out
future, err := conn.FetchUpdated(ctx, identity, time.Now().Add(time.Hour))
if err != nil {
t.Fatal(err)
}
if len(future) != 0 {
t.Fatalf("future since-watermark returned %d tickets", len(future))
}
t.Log("✓ since-filter")
// unknown identity errors
if _, err := conn.FetchUpdated(ctx, "definitely-not-a-member", time.Unix(0, 0)); err == nil {
t.Fatal("unknown identity must error")
}
t.Log("✓ unknown identity rejected")
}
+226
View File
@@ -0,0 +1,226 @@
package sync
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"bountyboard/internal/domain"
)
// fakeWekan implements just enough of the WeKan REST API: login, board with
// members, lists, cards, card details with assignees and timestamps.
func fakeWekan(t *testing.T, logins *atomic.Int64) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
authed := func(w http.ResponseWriter, r *http.Request) bool {
if r.Header.Get("Authorization") != "Bearer wekan-token-1" {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return false
}
return true
}
js := func(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
mux.HandleFunc("POST /users/login", func(w http.ResponseWriter, r *http.Request) {
var req struct{ Username, Password string }
json.NewDecoder(r.Body).Decode(&req)
if req.Username != "syncbot" || req.Password != "hunter2" {
http.Error(w, `{"error":"bad credentials"}`, http.StatusUnauthorized)
return
}
if logins != nil {
logins.Add(1)
}
js(w, map[string]string{"id": "u-syncbot", "token": "wekan-token-1"})
})
mux.HandleFunc("GET /api/boards/board1", func(w http.ResponseWriter, r *http.Request) {
if !authed(w, r) {
return
}
js(w, map[string]any{
"title": "Sprint Board", "slug": "sprint-board",
"members": []map[string]any{
{"userId": "u-clara"}, {"userId": "u-other"}, {"userId": "u-syncbot"},
},
})
})
mux.HandleFunc("GET /api/users/{id}", func(w http.ResponseWriter, r *http.Request) {
if !authed(w, r) {
return
}
names := map[string]string{"u-clara": "clara", "u-other": "othello", "u-syncbot": "syncbot"}
js(w, map[string]string{"username": names[r.PathValue("id")]})
})
mux.HandleFunc("GET /api/boards/board1/lists", func(w http.ResponseWriter, r *http.Request) {
if !authed(w, r) {
return
}
js(w, []map[string]string{{"_id": "l-todo", "title": "Todo"}, {"_id": "l-doing", "title": "Doing"}})
})
mux.HandleFunc("GET /api/boards/board1/lists/{list}/cards", func(w http.ResponseWriter, r *http.Request) {
if !authed(w, r) {
return
}
switch r.PathValue("list") {
case "l-todo":
js(w, []map[string]string{{"_id": "card-a"}, {"_id": "card-b"}, {"_id": "card-d"}})
default:
js(w, []map[string]string{{"_id": "card-c"}})
}
})
cards := map[string]map[string]any{
// assigned to clara, recently modified
"card-a": {"_id": "card-a", "title": "Fix the login flow", "description": "details A",
"assignees": []string{"u-clara"}, "modifiedAt": time.Now().UTC().Format(time.RFC3339)},
// member-fallback assignment, old timestamp
"card-b": {"_id": "card-b", "title": "Old card", "description": "details B",
"assignees": []string{}, "members": []string{"u-clara"},
"modifiedAt": "2020-01-01T00:00:00Z"},
// assigned to someone else
"card-c": {"_id": "card-c", "title": "Not clara's", "description": "details C",
"assignees": []string{"u-other"}, "modifiedAt": time.Now().UTC().Format(time.RFC3339)},
// clara's but archived
"card-d": {"_id": "card-d", "title": "Archived card", "description": "details D",
"assignees": []string{"u-clara"}, "archived": true,
"modifiedAt": time.Now().UTC().Format(time.RFC3339)},
}
mux.HandleFunc("GET /api/boards/board1/lists/{list}/cards/{card}", func(w http.ResponseWriter, r *http.Request) {
if !authed(w, r) {
return
}
c, ok := cards[r.PathValue("card")]
if !ok {
http.NotFound(w, r)
return
}
js(w, c)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func newWekan(t *testing.T, srv *httptest.Server) Connector {
t.Helper()
conn, err := NewConnector(domain.TicketingWekan, srv.URL, "board1",
Credentials{"username": "syncbot", "password": "hunter2"})
if err != nil {
t.Fatal(err)
}
return conn
}
func TestWekanFactoryValidation(t *testing.T) {
if _, err := NewConnector(domain.TicketingWekan, "https://w.example", "board1", Credentials{}); err == nil {
t.Fatal("missing credentials must be rejected")
}
if _, err := NewConnector(domain.TicketingWekan, "", "board1",
Credentials{"username": "u", "password": "p"}); err == nil {
t.Fatal("missing baseUrl must be rejected")
}
if _, err := NewConnector(domain.TicketingWekan, "https://w.example", "",
Credentials{"username": "u", "password": "p"}); err == nil {
t.Fatal("missing board id must be rejected")
}
if _, err := NewConnector(domain.TicketingWekan, "https://w.example", "board1",
Credentials{"token": "tok", "userId": "u1"}); err != nil {
t.Fatalf("pre-issued token must be accepted: %v", err)
}
}
func TestWekanTestConnection(t *testing.T) {
srv := fakeWekan(t, nil)
if err := newWekan(t, srv).TestConnection(context.Background()); err != nil {
t.Fatalf("test connection: %v", err)
}
bad, err := NewConnector(domain.TicketingWekan, srv.URL, "board1",
Credentials{"username": "syncbot", "password": "wrong"})
if err != nil {
t.Fatal(err)
}
if err := bad.TestConnection(context.Background()); err == nil {
t.Fatal("wrong password must fail the connection test")
}
}
func TestWekanFetchUpdated(t *testing.T) {
srv := fakeWekan(t, nil)
conn := newWekan(t, srv)
// since the epoch: clara gets card-a (assignee) and card-b (member
// fallback); card-c is someone else's, card-d is archived
tickets, err := conn.FetchUpdated(context.Background(), "clara", time.Unix(0, 0))
if err != nil {
t.Fatal(err)
}
if len(tickets) != 2 {
t.Fatalf("tickets = %d, want 2 (%+v)", len(tickets), tickets)
}
byKey := map[string]Ticket{}
for _, tk := range tickets {
byKey[tk.Key] = tk
}
a, ok := byKey["card-a"]
if !ok {
t.Fatal("card-a missing")
}
if a.Title != "Fix the login flow" || a.Type != "task" {
t.Fatalf("card-a mapped wrong: %+v", a)
}
if a.URL != srv.URL+"/b/board1/sprint-board/card-a" {
t.Fatalf("card url: %s", a.URL)
}
if a.Raw["list"] != "Todo" || a.Raw["board"] != "Sprint Board" {
t.Fatalf("raw payload: %+v", a.Raw)
}
// recent since-watermark filters out the old card-b
tickets, err = conn.FetchUpdated(context.Background(), "clara", time.Now().Add(-time.Hour))
if err != nil {
t.Fatal(err)
}
if len(tickets) != 1 || tickets[0].Key != "card-a" {
t.Fatalf("since filter: %+v", tickets)
}
// identity casing is forgiving
if _, err := conn.FetchUpdated(context.Background(), "CLARA", time.Unix(0, 0)); err != nil {
t.Fatalf("case-insensitive identity: %v", err)
}
// unknown identity errors clearly
if _, err := conn.FetchUpdated(context.Background(), "nobody", time.Unix(0, 0)); err == nil {
t.Fatal("unknown wekan user must error")
}
}
func TestWekanListAssignedKeysAndTokenReuse(t *testing.T) {
var logins atomic.Int64
srv := fakeWekan(t, &logins)
conn := newWekan(t, srv)
keys, err := conn.ListAssignedKeys(context.Background(), "clara")
if err != nil {
t.Fatal(err)
}
if len(keys) != 2 {
t.Fatalf("keys = %v, want card-a + card-b", keys)
}
// several operations reuse one login token instead of re-authenticating
if _, err := conn.FetchUpdated(context.Background(), "clara", time.Unix(0, 0)); err != nil {
t.Fatal(err)
}
if logins.Load() != 1 {
t.Fatalf("logins = %d, want 1 (token reuse)", logins.Load())
}
}
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env bash
# Live acceptance checklist (§13) against a running, seeded stack with the
# mocks profile. Exercises: demo ticket import → subdivide → extend →
# publish → claim/decline/approve → work → review → award → AI assignment →
# breaker independence.
set -euo pipefail
BASE="${BASE_URL:-http://localhost:8787}"
PASS="demo-pass-123"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
fail() { echo "ACCEPTANCE FAIL: $*" >&2; exit 1; }
ok() { echo "$*"; }
jqr() { python3 -c "import sys,json;d=json.load(sys.stdin);print(eval(sys.argv[1]))" "$1"; }
login() { # $1 email → jar at $TMP/$1.jar, csrf in $TMP/$1.csrf
local jar="$TMP/$1.jar"
curl -fsS -c "$jar" -H 'Content-Type: application/json' \
-d "{\"email\":\"$1\",\"password\":\"$PASS\"}" "$BASE/api/v1/auth/login" -o /dev/null \
|| fail "login $1"
awk '$6=="bb_csrf" {print $7}' "$jar" > "$TMP/$1.csrf"
}
api() { # $1 user, $2 method, $3 path, [$4 json body] → stdout
local jar="$TMP/$1.jar" csrf
csrf=$(cat "$TMP/$1.csrf")
if [ $# -ge 4 ]; then
curl -fsS -b "$jar" -X "$2" -H "X-CSRF-Token: $csrf" \
-H 'Content-Type: application/json' -d "$4" "$BASE$3"
else
curl -fsS -b "$jar" -X "$2" -H "X-CSRF-Token: $csrf" "$BASE$3"
fi
}
echo "1. consultant login + demo tickets imported within one poll interval"
login clara@example.com
for i in $(seq 1 24); do
COUNT=$(api clara@example.com GET /api/v1/consultant/board | jqr "len(d['tasks'])")
[ "$COUNT" -ge 5 ] && break
sleep 5
done
[ "$COUNT" -ge 5 ] || fail "expected ≥5 imported demo tickets, got $COUNT"
ok "demo sync imported $COUNT tickets"
BOARD=$(api clara@example.com GET /api/v1/consultant/board)
ROOT=$(echo "$BOARD" | jqr "[t for t in d['tasks'] if t['status']=='imported'][0]['id']")
ROOT_BUDGET=$(echo "$BOARD" | jqr "[t for t in d['tasks'] if t['id']=='$ROOT'][0]['budget']")
ok "picked imported root $ROOT (budget $ROOT_BUDGET)"
echo "2. subdivide → N children, coefficients sum to 1, editable"
api clara@example.com POST "/api/v1/tasks/$ROOT/subdivide" \
'{"note":"split it","constraints":{"minTasks":3,"maxTasks":3}}' > /dev/null
for i in $(seq 1 30); do
KIDS=$(api clara@example.com GET "/api/v1/consultant/board" | \
jqr "len([t for t in d['tasks'] if t.get('parentId')=='$ROOT'])")
[ "$KIDS" -ge 3 ] && break
sleep 2
done
[ "$KIDS" -ge 3 ] || fail "subdivision produced $KIDS children"
SUM=$(api clara@example.com GET "/api/v1/consultant/board" | \
jqr "round(sum(t['effortCoefficient'] for t in d['tasks'] if t.get('parentId')=='$ROOT' and t['origin']=='subdivided'),4)")
[ "$SUM" = "1.0" ] || fail "children coefficients sum to $SUM"
ok "3 children, coefficient sum exactly 1.0"
CHILD1=$(api clara@example.com GET "/api/v1/consultant/board" | \
jqr "[t for t in d['tasks'] if t.get('parentId')=='$ROOT'][0]['id']")
CHILD1V=$(api clara@example.com GET "/api/v1/tasks/$CHILD1" | jqr "d['task']['version']")
api clara@example.com PATCH "/api/v1/tasks/$CHILD1" \
"{\"version\":$CHILD1V,\"effortCoefficient\":0.5,\"budget\":2000}" > /dev/null
B=$(api clara@example.com GET "/api/v1/tasks/$CHILD1" | jqr "float(d['task']['bounty'])")
[ "$B" = "1000.0" ] || fail "edited bounty = $B, want 1000 (0.5 × 2000)"
ok "edit recomputed bounty = coefficient × budget"
echo "3. extend creates exactly one sibling"
BEFORE=$(api clara@example.com GET /api/v1/consultant/board | \
jqr "len([t for t in d['tasks'] if t['origin']=='extended'])")
api clara@example.com POST "/api/v1/tasks/$ROOT/extend" '{"note":"add CSV presets"}' > /dev/null
for i in $(seq 1 30); do
AFTER=$(api clara@example.com GET /api/v1/consultant/board | \
jqr "len([t for t in d['tasks'] if t['origin']=='extended'])")
[ "$AFTER" -eq $((BEFORE + 1)) ] && break
sleep 2
done
[ "$AFTER" -eq $((BEFORE + 1)) ] || fail "extension count $AFTER (was $BEFORE)"
ok "extension created exactly one sibling"
echo "4. publish → developer board with bounty = coefficient × budget"
api clara@example.com POST "/api/v1/tasks/$CHILD1/publish" '{}' > /dev/null
login dev1@example.com
DEVB=$(api dev1@example.com GET /api/v1/board)
DB=$(echo "$DEVB" | jqr "float([t for t in d['tasks'] if t['id']=='$CHILD1'][0]['bounty'])")
[ "$DB" = "1000.0" ] || fail "published task not on dev board with bounty 1000 (got $DB)"
ok "developer sees published task, bounty 1000"
echo "5. claim → decline → claim → approve → work → changes → approve → award"
api dev1@example.com POST "/api/v1/tasks/$CHILD1/claim" '{"note":"mine!"}' > /dev/null
api clara@example.com POST "/api/v1/tasks/$CHILD1/decline-claim" "{\"developerId\":\"$(api dev1@example.com GET /api/v1/auth/me | jqr "d['user']['id']")\"}" > /dev/null
ST=$(api clara@example.com GET "/api/v1/tasks/$CHILD1" | jqr "d['task']['status']")
[ "$ST" = "published" ] || fail "after decline: $ST"
ok "decline returned task to published"
TOTAL_BEFORE=$(api dev1@example.com GET /api/v1/developer/metrics | jqr "float(d['totalBounty'])")
api dev1@example.com POST "/api/v1/tasks/$CHILD1/claim" '{"note":"please"}' > /dev/null
DEVID=$(api dev1@example.com GET /api/v1/auth/me | jqr "d['user']['id']")
api clara@example.com POST "/api/v1/tasks/$CHILD1/approve-claim" "{\"developerId\":\"$DEVID\"}" > /dev/null
api dev1@example.com POST "/api/v1/tasks/$CHILD1/start" '{}' > /dev/null
api dev1@example.com POST "/api/v1/tasks/$CHILD1/comments" '{"body":"<p>done, see <b>notes</b></p>"}' > /dev/null
api dev1@example.com POST "/api/v1/tasks/$CHILD1/time" '{"minutes":45,"note":"impl"}' > /dev/null
api dev1@example.com POST "/api/v1/tasks/$CHILD1/submit-review" '{}' > /dev/null
api clara@example.com POST "/api/v1/tasks/$CHILD1/review" '{"decision":"request_changes","note":"edge cases"}' > /dev/null
api dev1@example.com POST "/api/v1/tasks/$CHILD1/start" '{}' > /dev/null
api dev1@example.com POST "/api/v1/tasks/$CHILD1/submit-review" '{}' > /dev/null
api clara@example.com POST "/api/v1/tasks/$CHILD1/review" \
'{"decision":"approve","note":"good","checklist":[{"criterion":"works","ok":true}]}' > /dev/null
ST=$(api clara@example.com GET "/api/v1/tasks/$CHILD1" | jqr "d['task']['status']")
[ "$ST" = "approved" ] || fail "after approve: $ST"
TOTAL=$(api dev1@example.com GET /api/v1/developer/metrics | jqr "float(d['totalBounty'])")
DELTA=$(python3 -c "print($TOTAL - $TOTAL_BEFORE)")
[ "$DELTA" = "1000.0" ] || fail "developer metrics delta $DELTA, want 1000"
ok "full lifecycle approved; bountyAwards +1000 reflected in metrics"
echo "6. unassign returns an assigned task to the board"
CHILD2=$(api clara@example.com GET "/api/v1/consultant/board" | \
jqr "[t for t in d['tasks'] if t.get('parentId')=='$ROOT' and t['status']=='atomized'][0]['id']")
api clara@example.com POST "/api/v1/tasks/$CHILD2/publish" '{}' > /dev/null
api dev1@example.com POST "/api/v1/tasks/$CHILD2/claim" '{}' > /dev/null
api clara@example.com POST "/api/v1/tasks/$CHILD2/approve-claim" "{\"developerId\":\"$DEVID\"}" > /dev/null
api clara@example.com POST "/api/v1/tasks/$CHILD2/unassign" '{}' > /dev/null
ST=$(api clara@example.com GET "/api/v1/tasks/$CHILD2" | jqr "d['task']['status']")
[ "$ST" = "published" ] || fail "after unassign: $ST"
ok "unassign returned task to published"
echo "7. AI assignment → §5.2 job → signed callback → in_review"
api clara@example.com POST "/api/v1/tasks/$CHILD2/assign-ai" \
'{"context":{"instructions":"create SUMMARY.md only; do not write code"}}' > /dev/null
for i in $(seq 1 90); do
ST=$(api clara@example.com GET "/api/v1/tasks/$CHILD2" | jqr "d['task']['status']")
[ "$ST" = "in_review" ] && break
sleep 5
done
[ "$ST" = "in_review" ] || fail "AI task status after wait: $ST"
ok "work performer callback moved the task to in_review"
api clara@example.com POST "/api/v1/tasks/$CHILD2/review" '{"decision":"approve","note":"AI ok"}' > /dev/null
ok "consultant reviewed AI work like a human's"
echo "8. breaker independence: stopping the atomizer must not affect the work performer"
sudo -n docker compose --profile mocks stop atomizer-mock > /dev/null 2>&1 \
|| docker compose --profile mocks stop atomizer-mock > /dev/null
CHILD3=$(api clara@example.com GET "/api/v1/consultant/board" | \
jqr "[t for t in d['tasks'] if t['status']=='atomized'][0]['id']")
for i in 1 2 3; do
api clara@example.com POST "/api/v1/tasks/$CHILD3/subdivide" '{"note":"x","confirmReplace":true}' > /dev/null || true
sleep 16
done
HEALTH=$(api clara@example.com GET /api/v1/service-health)
A_OK=$(echo "$HEALTH" | jqr "d['atomizer']['healthy']")
W_OK=$(echo "$HEALTH" | jqr "d['workPerformer']['healthy']")
[ "$A_OK" = "False" ] || fail "atomizer should be down"
[ "$W_OK" = "True" ] || fail "work performer must be unaffected"
BREAKER=$(echo "$HEALTH" | jqr "d['atomizer']['breaker']")
ok "atomizer down (breaker: $BREAKER), work performer healthy — independent"
sudo -n docker compose --profile mocks start atomizer-mock > /dev/null 2>&1 \
|| docker compose --profile mocks start atomizer-mock > /dev/null
ok "atomizer restarted"
echo
echo "ACCEPTANCE PASS"
+187
View File
@@ -0,0 +1,187 @@
// Command seed populates a demo environment (§11.11): one demo customer
// (offline ticketing type), 1 admin, 2 consultants, 6 developers, pool
// memberships, and sample conversations. Idempotent: existing users (by
// email) and customers (by name) are reused, not duplicated.
//
// MONGO_SEED_URI=mongodb://127.0.0.1:27017 go run ./scripts
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"bountyboard/internal/auth"
"bountyboard/internal/config"
"bountyboard/internal/domain"
"bountyboard/internal/store"
"bountyboard/internal/ulid"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
const demoPassword = "demo-pass-123"
func main() {
_ = config.LoadDotEnv(".env")
uri := os.Getenv("MONGO_SEED_URI")
if uri == "" {
uri = "mongodb://127.0.0.1:27017"
}
dbName := os.Getenv("MONGO_DB")
if dbName == "" {
dbName = "bountyboard"
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
client, err := mongo.Connect(options.Client().ApplyURI(uri).
SetServerSelectionTimeout(5 * time.Second))
if err != nil {
log.Fatalf("connect: %v", err)
}
defer client.Disconnect(context.Background())
st := &store.Store{Client: client, DB: client.Database(dbName)}
if err := store.EnsureIndexes(ctx, st.DB); err != nil {
log.Fatalf("indexes: %v", err)
}
user := func(email, name string, roles domain.Roles) *domain.User {
if u, err := st.UserByEmail(ctx, email); err == nil {
fmt.Printf(" exists: %s\n", email)
return u
}
hash, err := auth.HashPassword(demoPassword)
if err != nil {
log.Fatal(err)
}
u := &domain.User{
ID: ulid.New(), Email: email, Name: name, Roles: roles,
Auth: domain.Auth{Local: &domain.LocalAuth{PasswordHash: hash}},
Settings: domain.UserSettings{Theme: "light",
Notifications: domain.NotificationPrefs{Email: true, InApp: true}},
Bio: "Seeded demo account.",
}
if err := st.CreateUser(ctx, u); err != nil {
log.Fatalf("create %s: %v", email, err)
}
fmt.Printf(" created: %s (password %q)\n", email, demoPassword)
return u
}
fmt.Println("Seeding users…")
user("seed-admin@example.com", "Seed Admin", domain.Roles{Admin: true})
clara := user("clara@example.com", "Clara Consultant", domain.Roles{Consultant: true})
carlos := user("carlos@example.com", "Carlos Consultant", domain.Roles{Consultant: true})
devs := []*domain.User{}
devNames := []string{"Dana", "Devon", "Dimitri", "Daria", "Dylan", "Drew"}
for i, n := range devNames {
devs = append(devs, user(fmt.Sprintf("dev%d@example.com", i+1),
n+" Developer", domain.Roles{Developer: true}))
}
fmt.Println("Seeding pools…")
addPool := func(c, d *domain.User) {
if err := st.AddToPool(ctx, c.ID, d.ID); err != nil && err != store.ErrDuplicate {
log.Fatalf("pool: %v", err)
}
}
for _, d := range devs[:4] {
addPool(clara, d)
}
for _, d := range devs[2:] {
addPool(carlos, d)
}
fmt.Println("Seeding demo customer…")
customers, err := st.ListCustomers(ctx, "", true)
if err != nil {
log.Fatal(err)
}
var demoCustomer *domain.Customer
for i := range customers {
if customers[i].Name == "ACME Demo" {
demoCustomer = &customers[i]
}
}
if demoCustomer == nil {
demoCustomer = &domain.Customer{
Name: "ACME Demo",
Ticketing: domain.Ticketing{
Type: domain.TicketingDemo, ProjectKey: "ACME", PollIntervalSec: 30,
},
ConsultantIDs: []string{clara.ID, carlos.ID},
DefaultBudget: 1500,
}
if err := st.CreateCustomer(ctx, demoCustomer); err != nil {
log.Fatal(err)
}
fmt.Println(" created: ACME Demo (demo ticketing — tickets appear within one poll)")
} else {
fmt.Println(" exists: ACME Demo")
}
fmt.Println("Seeding conversations…")
dm, err := st.FindOrCreateDM(ctx, clara.ID, devs[0].ID)
if err != nil {
log.Fatal(err)
}
n, err := st.DB.Collection("messages").CountDocuments(ctx, bson.M{"conversationId": dm.ID})
if err != nil {
log.Fatal(err)
}
if n == 0 {
msgs := []struct {
from *domain.User
body string
}{
{clara, "<p>Hi Dana — I just added you to my pool. The ACME board fills up shortly.</p>"},
{devs[0], "<p>Great, I will grab the <b>CSV importer</b> once it is published.</p>"},
{clara, "<p>Perfect. Ping me here if any acceptance criteria are unclear.</p>"},
}
for _, m := range msgs {
if err := st.InsertMessage(ctx, &store.Message{
ConversationID: dm.ID, SenderID: m.from.ID, Body: m.body,
}); err != nil {
log.Fatal(err)
}
}
fmt.Println(" created: DM Clara ↔ Dana with 3 messages")
}
groups, _ := st.ListConversations(ctx, clara.ID)
hasGroup := false
for _, g := range groups {
if g.Kind == "group" && g.Title == "ACME standup" {
hasGroup = true
}
}
if !hasGroup {
grp := &store.Conversation{
Kind: "group", Title: "ACME standup",
ParticipantIDs: []string{clara.ID, carlos.ID, devs[0].ID, devs[1].ID, devs[2].ID},
}
if err := st.CreateConversation(ctx, grp); err != nil {
log.Fatal(err)
}
if err := st.InsertMessage(ctx, &store.Message{
ConversationID: grp.ID, SenderID: carlos.ID,
Body: "<p>Welcome to the ACME standup channel. Daily updates here, please.</p>",
}); err != nil {
log.Fatal(err)
}
fmt.Println(" created: group 'ACME standup'")
}
fmt.Println("\nSeed complete.")
fmt.Printf("Demo accounts (password %q):\n", demoPassword)
fmt.Println(" seed-admin@example.com (admin)")
fmt.Println(" clara@example.com, carlos@example.com (consultants)")
for i := range devNames {
fmt.Printf(" dev%d@example.com (developer)\n", i+1)
}
}
+39 -6
View File
@@ -1,28 +1,61 @@
#!/usr/bin/env bash
# Curl-based smoke test against a running stack (docker compose up -d).
# Grows with the system; later phases add register→login→board coverage.
# Curl-based smoke test (§13): register → login → healthz → board against a
# running stack (docker compose up -d).
set -euo pipefail
BASE_URL="${BASE_URL:-http://localhost:8787}"
JAR=$(mktemp)
trap 'rm -f "$JAR" /tmp/smoke-*.json' EXIT
fail() { echo "SMOKE FAIL: $*" >&2; exit 1; }
echo "smoke: $BASE_URL"
# healthz must always be 200 while the process is up
# 1. health endpoints
code=$(curl -fsS -o /tmp/smoke-healthz.json -w '%{http_code}' "$BASE_URL/healthz") \
|| fail "healthz unreachable"
[ "$code" = "200" ] || fail "healthz returned $code"
grep -q '"ok"' /tmp/smoke-healthz.json || fail "healthz body unexpected: $(cat /tmp/smoke-healthz.json)"
grep -q '"ok"' /tmp/smoke-healthz.json || fail "healthz body unexpected"
echo " healthz ok"
# readyz reflects dependency state; required deps must be up for the smoke run
code=$(curl -sS -o /tmp/smoke-readyz.json -w '%{http_code}' "$BASE_URL/readyz") \
|| fail "readyz unreachable"
[ "$code" = "200" ] || fail "readyz returned $code: $(cat /tmp/smoke-readyz.json)"
echo " readyz ok"
# metricsz exposes counters
curl -fsS "$BASE_URL/metricsz" | grep -q '"counters"' || fail "metricsz body unexpected"
echo " metricsz ok"
# 2. register a throwaway developer
EMAIL="smoke-$(date +%s)-$RANDOM@example.com"
code=$(curl -sS -c "$JAR" -o /tmp/smoke-reg.json -w '%{http_code}' \
-H 'Content-Type: application/json' \
-d "{\"email\":\"$EMAIL\",\"name\":\"Smoke Test\",\"password\":\"smoke-pass-123\"}" \
"$BASE_URL/api/v1/auth/register")
[ "$code" = "201" ] || fail "register returned $code: $(cat /tmp/smoke-reg.json)"
echo " register ok ($EMAIL)"
# 3. logout, then login again
CSRF=$(awk '$6=="bb_csrf" {print $7}' "$JAR")
curl -fsS -b "$JAR" -X POST -H "X-CSRF-Token: $CSRF" \
"$BASE_URL/api/v1/auth/logout" -o /dev/null || fail "logout failed"
code=$(curl -sS -c "$JAR" -o /tmp/smoke-login.json -w '%{http_code}' \
-H 'Content-Type: application/json' \
-d "{\"email\":\"$EMAIL\",\"password\":\"smoke-pass-123\"}" \
"$BASE_URL/api/v1/auth/login")
[ "$code" = "200" ] || fail "login returned $code"
echo " login ok"
# 4. authenticated me + developer board
curl -fsS -b "$JAR" "$BASE_URL/api/v1/auth/me" | grep -q "$EMAIL" || fail "me missing email"
echo " me ok"
code=$(curl -sS -b "$JAR" -o /tmp/smoke-board.json -w '%{http_code}' "$BASE_URL/api/v1/board")
[ "$code" = "200" ] || fail "board returned $code: $(cat /tmp/smoke-board.json)"
grep -q '"tasks"' /tmp/smoke-board.json || fail "board body unexpected"
echo " board ok"
# 5. pages render
curl -fsS "$BASE_URL/login" | grep -q 'Log in · Bounty Board' || fail "login page broken"
curl -fsS "$BASE_URL/api/docs" | grep -q 'Bounty Board API' || fail "api docs broken"
echo " pages ok"
echo "SMOKE PASS"
+14
View File
@@ -0,0 +1,14 @@
# --- build ---
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod ./
COPY *.go ./
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/atomizer .
# --- runtime ---
FROM alpine:3.21
RUN apk add --no-cache ca-certificates && adduser -S -G nogroup app
USER app
COPY --from=build /out/atomizer /usr/local/bin/atomizer
EXPOSE 8090
ENTRYPOINT ["/usr/local/bin/atomizer"]
BIN
View File
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
module atomizer-mock
go 1.26
+252
View File
@@ -0,0 +1,252 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
)
// llmClient calls Anthropic via the OpenAI-compatible chat-completions
// endpoint (default) or the native Messages API (LLM_API_STYLE=anthropic).
// Plain net/http, no SDK (§9.1).
type llmClient struct {
cfg config
log *slog.Logger
http *http.Client
}
func newLLMClient(cfg config, log *slog.Logger) *llmClient {
return &llmClient{cfg: cfg, log: log, http: &http.Client{Timeout: 110 * time.Second}}
}
const atomizeSystem = `You are an expert software project planner. You split one ticket into small, well-scoped, independently deliverable developer tasks.
Respond with ONLY a JSON object, no prose and no markdown fences, exactly matching:
{"tasks":[{"title":"...","description":"...","acceptanceCriteria":["..."],"effortCoefficient":0.25}],"notes":"short advice for the consultant"}
Rules: between MIN and MAX tasks; every effortCoefficient is a number in (0,1]; all effortCoefficients MUST sum to exactly 1.0; titles are concise; descriptions are self-contained instructions; each task has 1-4 testable acceptance criteria.`
const extendSystem = `You are an expert software project planner. You design exactly ONE additional sibling task that extends the given task's functionality per the extension note.
Respond with ONLY a JSON object, no prose and no markdown fences, exactly matching:
{"task":{"title":"...","description":"...","acceptanceCriteria":["..."],"effortCoefficient":0.4},"notes":"short advice"}
Rules: effortCoefficient is the effort RELATIVE to the source task, a number in (0,2].`
func ticketPrompt(req atomizeRequest) string {
var sb strings.Builder
fmt.Fprintf(&sb, "TICKET: %s\n\nDESCRIPTION:\n%s\n", req.Title, req.Description)
if len(req.AcceptanceCriteria) > 0 {
sb.WriteString("\nACCEPTANCE CRITERIA:\n")
for _, c := range req.AcceptanceCriteria {
fmt.Fprintf(&sb, "- %s\n", c)
}
}
if len(req.Links) > 0 {
fmt.Fprintf(&sb, "\nLINKS: %s\n", strings.Join(req.Links, ", "))
}
for _, a := range req.Attachments {
fmt.Fprintf(&sb, "ATTACHMENT: %s (%s) %s\n", a.Name, a.MimeType, a.URL)
}
return sb.String()
}
func (l *llmClient) atomize(ctx context.Context, req atomizeRequest, minT, maxT int) ([]subTask, string, string, error) {
if l.cfg.apiKey == "" {
return fallbackAtomize(req, minT, maxT), "offline-fallback", "Deterministic split (no ANTHROPIC_API_KEY configured).", nil
}
system := strings.NewReplacer("MIN", fmt.Sprint(minT), "MAX", fmt.Sprint(maxT)).Replace(atomizeSystem)
user := ticketPrompt(req)
if req.SubdivisionNote != "" {
user += "\nCONSULTANT NOTE: " + req.SubdivisionNote
}
var parsed struct {
Tasks []subTask `json:"tasks"`
Notes string `json:"notes"`
}
raw, err := l.completeWithRetry(ctx, system, user, func(text string) error {
if err := json.Unmarshal([]byte(stripFences(text)), &parsed); err != nil {
return err
}
if len(parsed.Tasks) == 0 {
return fmt.Errorf("no tasks in response")
}
for _, t := range parsed.Tasks {
if t.Title == "" || t.EffortCoefficient <= 0 {
return fmt.Errorf("invalid task entry")
}
}
return nil
})
if err != nil {
l.log.Warn("llm atomize failed, using fallback", "err", err)
return fallbackAtomize(req, minT, maxT), "offline-fallback",
"LLM unavailable (" + err.Error() + "); deterministic split.", nil
}
_ = raw
return parsed.Tasks, l.cfg.model, parsed.Notes, nil
}
func (l *llmClient) extend(ctx context.Context, req atomizeRequest) (subTask, string, string, error) {
if l.cfg.apiKey == "" {
return fallbackExtend(req), "offline-fallback", "Deterministic extension (no ANTHROPIC_API_KEY configured).", nil
}
user := ticketPrompt(req) + "\nEXTENSION NOTE (required scope): " + req.ExtensionNote
var parsed struct {
Task subTask `json:"task"`
Notes string `json:"notes"`
}
_, err := l.completeWithRetry(ctx, extendSystem, user, func(text string) error {
if err := json.Unmarshal([]byte(stripFences(text)), &parsed); err != nil {
return err
}
if parsed.Task.Title == "" {
return fmt.Errorf("no task in response")
}
return nil
})
if err != nil {
l.log.Warn("llm extend failed, using fallback", "err", err)
return fallbackExtend(req), "offline-fallback",
"LLM unavailable (" + err.Error() + "); deterministic extension.", nil
}
return parsed.Task, l.cfg.model, parsed.Notes, nil
}
// completeWithRetry runs the chat completion and retries ONCE on parse
// failure with a corrective hint (§9.1).
func (l *llmClient) completeWithRetry(ctx context.Context, system, user string,
validate func(string) error) (string, error) {
text, err := l.complete(ctx, system, user)
if err != nil {
return "", err
}
if vErr := validate(text); vErr == nil {
return text, nil
} else {
l.log.Warn("llm response failed validation, retrying once", "err", vErr)
}
text, err = l.complete(ctx, system,
user+"\n\nIMPORTANT: your previous reply was not valid JSON matching the schema. Reply with ONLY the JSON object.")
if err != nil {
return "", err
}
if vErr := validate(text); vErr != nil {
return "", fmt.Errorf("invalid JSON after retry: %w", vErr)
}
return text, nil
}
func (l *llmClient) complete(ctx context.Context, system, user string) (string, error) {
if l.cfg.apiStyle == "anthropic" {
return l.completeNative(ctx, system, user)
}
return l.completeOpenAI(ctx, system, user)
}
// completeOpenAI uses Anthropic's OpenAI-compatible endpoint.
func (l *llmClient) completeOpenAI(ctx context.Context, system, user string) (string, error) {
body, _ := json.Marshal(map[string]any{
"model": l.cfg.model,
"max_tokens": 4096,
"messages": []map[string]string{
{"role": "system", "content": system},
{"role": "user", "content": user},
},
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
l.cfg.baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+l.cfg.apiKey)
data, err := l.do(req)
if err != nil {
return "", err
}
var out struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(data, &out); err != nil {
return "", fmt.Errorf("decode chat completion: %w", err)
}
if len(out.Choices) == 0 {
return "", fmt.Errorf("no choices in response")
}
return out.Choices[0].Message.Content, nil
}
// completeNative uses the native Anthropic Messages API (flip via
// LLM_API_STYLE=anthropic if the compatibility endpoint misbehaves, §9.1).
func (l *llmClient) completeNative(ctx context.Context, system, user string) (string, error) {
body, _ := json.Marshal(map[string]any{
"model": l.cfg.model,
"max_tokens": 4096,
"system": system,
"messages": []map[string]string{{"role": "user", "content": user}},
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
l.cfg.baseURL+"/messages", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", l.cfg.apiKey)
req.Header.Set("anthropic-version", "2023-06-01")
data, err := l.do(req)
if err != nil {
return "", err
}
var out struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(data, &out); err != nil {
return "", fmt.Errorf("decode messages response: %w", err)
}
for _, c := range out.Content {
if c.Type == "text" {
return c.Text, nil
}
}
return "", fmt.Errorf("no text content in response")
}
func (l *llmClient) do(req *http.Request) ([]byte, error) {
resp, err := l.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("llm api status %d: %.300s", resp.StatusCode, data)
}
return data, nil
}
// stripFences defensively removes markdown code fences and surrounding prose
// (§9.1) by slicing from the first '{' to the last '}'.
func stripFences(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '{'); i >= 0 {
if j := strings.LastIndexByte(s, '}'); j > i {
return s[i : j+1]
}
}
return s
}
+243
View File
@@ -0,0 +1,243 @@
// atomizer-mock is the standalone placeholder Atomization Service (§5.1,
// §9.1). It calls Anthropic through the OpenAI-compatible chat-completions
// endpoint by default, or the native Messages API when
// LLM_API_STYLE=anthropic, and falls back to a deterministic equal split
// when no ANTHROPIC_API_KEY is configured so the whole system works offline.
package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
type config struct {
port string
token string
apiKey string
baseURL string
model string
apiStyle string // openai | anthropic
}
func loadConfig() config {
get := func(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
return config{
port: get("PORT", "8090"),
token: os.Getenv("ATOMIZER_TOKEN"),
apiKey: os.Getenv("ANTHROPIC_API_KEY"),
baseURL: strings.TrimRight(get("ANTHROPIC_OPENAI_BASE_URL", "https://api.anthropic.com/v1"), "/"),
model: get("ANTHROPIC_MODEL", "claude-sonnet-4-6"),
apiStyle: get("LLM_API_STYLE", "openai"),
}
}
type subTask struct {
Title string `json:"title"`
Description string `json:"description"`
AcceptanceCriteria []string `json:"acceptanceCriteria"`
EffortCoefficient float64 `json:"effortCoefficient"`
}
type atomizeRequest struct {
TaskID string `json:"taskId"`
Title string `json:"title"`
Description string `json:"description"`
AcceptanceCriteria []string `json:"acceptanceCriteria"`
Attachments []struct {
Name string `json:"name"`
URL string `json:"url"`
MimeType string `json:"mimeType"`
} `json:"attachments"`
Links []string `json:"links"`
SubdivisionNote string `json:"subdivisionNote"`
ExtensionNote string `json:"extensionNote"`
Constraints *struct {
MinTasks int `json:"minTasks"`
MaxTasks int `json:"maxTasks"`
} `json:"constraints"`
}
type server struct {
cfg config
log *slog.Logger
llm *llmClient
}
func main() {
cfg := loadConfig()
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
s := &server{cfg: cfg, log: log, llm: newLLMClient(cfg, log)}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc("POST /v1/atomize", s.auth(s.handleAtomize))
mux.HandleFunc("POST /v1/extend", s.auth(s.handleExtend))
srv := &http.Server{Addr: ":" + cfg.port, Handler: mux, ReadHeaderTimeout: 10 * time.Second}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
log.Info("atomizer-mock listening", "port", cfg.port,
"llm", map[bool]string{true: "anthropic:" + cfg.apiStyle, false: "fallback (no api key)"}[cfg.apiKey != ""])
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Error("serve", "err", err)
os.Exit(1)
}
}()
<-ctx.Done()
shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(shutCtx)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, code, msg string) {
writeJSON(w, status, map[string]any{"error": map[string]string{"code": code, "message": msg}})
}
func (s *server) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.cfg.token != "" && r.Header.Get("Authorization") != "Bearer "+s.cfg.token {
writeErr(w, http.StatusUnauthorized, "unauthorized", "missing or invalid bearer token")
return
}
next(w, r)
}
}
func (s *server) handleAtomize(w http.ResponseWriter, r *http.Request) {
var req atomizeRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
minT, maxT := 2, 8
if req.Constraints != nil {
if req.Constraints.MinTasks > 0 {
minT = req.Constraints.MinTasks
}
if req.Constraints.MaxTasks > 0 {
maxT = req.Constraints.MaxTasks
}
}
if minT > maxT {
minT = maxT
}
tasks, model, notes, err := s.llm.atomize(r.Context(), req, minT, maxT)
if err != nil {
s.log.Error("atomize failed", "taskId", req.TaskID, "err", err)
writeErr(w, http.StatusBadGateway, "llm_failed", err.Error())
return
}
normalizeSum(tasks)
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "model": model, "notes": notes})
}
func (s *server) handleExtend(w http.ResponseWriter, r *http.Request) {
var req atomizeRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if strings.TrimSpace(req.ExtensionNote) == "" {
writeErr(w, http.StatusBadRequest, "note_required", "extensionNote is required")
return
}
task, model, notes, err := s.llm.extend(r.Context(), req)
if err != nil {
s.log.Error("extend failed", "taskId", req.TaskID, "err", err)
writeErr(w, http.StatusBadGateway, "llm_failed", err.Error())
return
}
if task.EffortCoefficient <= 0 || task.EffortCoefficient > 2 {
task.EffortCoefficient = 0.3
}
writeJSON(w, http.StatusOK, map[string]any{"task": task, "model": model, "notes": notes})
}
// normalizeSum forces coefficients to sum to exactly 1.0.
func normalizeSum(tasks []subTask) {
sum := 0.0
for _, t := range tasks {
sum += t.EffortCoefficient
}
if sum <= 0 {
eq := 1.0 / float64(len(tasks))
for i := range tasks {
tasks[i].EffortCoefficient = eq
}
sum = 1.0
}
total := 0.0
largest := 0
for i := range tasks {
tasks[i].EffortCoefficient = round4(tasks[i].EffortCoefficient / sum)
total += tasks[i].EffortCoefficient
if tasks[i].EffortCoefficient > tasks[largest].EffortCoefficient {
largest = i
}
}
tasks[largest].EffortCoefficient = round4(tasks[largest].EffortCoefficient + 1 - total)
}
func round4(v float64) float64 {
return float64(int64(v*10000+0.5)) / 10000
}
func fallbackAtomize(req atomizeRequest, minT, maxT int) []subTask {
n := (minT + maxT) / 2
if n < 1 {
n = 3
}
out := make([]subTask, n)
eq := round4(1.0 / float64(n))
for i := range out {
out[i] = subTask{
Title: fmt.Sprintf("%s — part %d of %d", req.Title, i+1, n),
Description: fmt.Sprintf("Deterministic offline split %d/%d of:\n\n%s", i+1, n, req.Description),
AcceptanceCriteria: append([]string{}, req.AcceptanceCriteria...),
EffortCoefficient: eq,
}
}
return out
}
func fallbackExtend(req atomizeRequest) subTask {
return subTask{
Title: req.Title + " — extension: " + firstLine(req.ExtensionNote, 60),
Description: "Deterministic offline extension of the source task.\n\nRequested scope: " + req.ExtensionNote,
AcceptanceCriteria: []string{"extension scope implemented: " + firstLine(req.ExtensionNote, 120)},
EffortCoefficient: 0.3,
}
}
func firstLine(s string, max int) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[:i]
}
if len(s) > max {
s = s[:max]
}
return s
}
+13
View File
@@ -0,0 +1,13 @@
FROM node:20-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& npm install -g @anthropic-ai/claude-code
WORKDIR /srv
COPY server.js .
# claude refuses --dangerously-skip-permissions as root: run as the node
# user (uid 1000, matches typical host ownership of the mounted ~/.claude)
RUN mkdir -p /work && chown node:node /work /srv
USER node
ENV HOME=/home/node
EXPOSE 8091
CMD ["node", "server.js"]
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env node
// work-performer: standalone placeholder Work Performer Service (§5.2, §9.2).
// Plain Node http server, no framework. For each job it prepares
// /work/{jobId}/TASK.md and runs the Claude Code CLI; if the CLI is missing
// or fails to start (e.g. no credentials mounted), it produces a simulated
// result so the whole flow stays demonstrable offline. The HTTP contract —
// not this implementation — is the deliverable.
'use strict';
const http = require('http');
const https = require('https');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { execFile, execFileSync } = require('child_process');
const PORT = parseInt(process.env.PORT || '8091', 10);
const TOKEN = process.env.WORK_PERFORMER_TOKEN || '';
const WORK_DIR = process.env.WORK_DIR || '/work';
const PUBLIC_BASE = process.env.PUBLIC_BASE_URL || `http://work-performer:${PORT}`;
const jobs = new Map(); // jobId -> {status, taskId, request, startedAt, finishedAt, cancel}
const queue = [];
let running = false; // single-job concurrency (§9.2)
const log = (msg, extra) =>
console.log(JSON.stringify({ ts: new Date().toISOString(), msg, ...extra }));
function json(res, status, body) {
const data = JSON.stringify(body);
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(data);
}
const errJson = (res, status, code, message) =>
json(res, status, { error: { code, message } });
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
req.on('data', (c) => {
size += c.length;
if (size > 4 << 20) { reject(new Error('body too large')); req.destroy(); return; }
chunks.push(c);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
function download(url, dest) {
return new Promise((resolve, reject) => {
const mod = url.startsWith('https:') ? https : http;
const file = fs.createWriteStream(dest);
mod.get(url, (res) => {
if (res.statusCode !== 200) {
file.close(); fs.rmSync(dest, { force: true });
reject(new Error(`download ${url}: status ${res.statusCode}`));
return;
}
res.pipe(file);
file.on('finish', () => file.close(resolve));
}).on('error', (e) => { file.close(); fs.rmSync(dest, { force: true }); reject(e); });
});
}
function postCallback(urlStr, payload) {
return new Promise((resolve, reject) => {
const body = Buffer.from(JSON.stringify(payload));
const sig = crypto.createHmac('sha256', TOKEN).update(body).digest('hex');
const url = new URL(urlStr);
const mod = url.protocol === 'https:' ? https : http;
const req = mod.request(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': body.length,
'X-Signature': sig,
},
}, (res) => { res.resume(); resolve(res.statusCode); });
req.on('error', reject);
req.end(body);
});
}
function claudeAvailable() {
try {
execFileSync('claude', ['--version'], { timeout: 15000, stdio: 'pipe' });
return true;
} catch (e) {
return false;
}
}
function buildTaskMD(r) {
const lines = [`# ${r.title}`, '', r.description || '', ''];
if ((r.acceptanceCriteria || []).length) {
lines.push('## Acceptance criteria', '');
r.acceptanceCriteria.forEach((c) => lines.push(`- ${c}`));
lines.push('');
}
if ((r.links || []).length) {
lines.push('## Links', '');
r.links.forEach((l) => lines.push(`- ${l}`));
lines.push('');
}
if ((r.attachments || []).length) {
lines.push('## Attachments (downloaded into ./attachments)', '');
r.attachments.forEach((a) => lines.push(`- ${a.name}`));
lines.push('');
}
if (r.context && r.context.instructions) {
lines.push('## Extra instructions', '', r.context.instructions, '');
}
lines.push('Produce your changes in this directory. Write a SUMMARY.md describing what you did.');
return lines.join('\n');
}
function listProducedFiles(dir, before) {
const out = [];
const walk = (d) => {
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
if (entry.name === '.git' || entry.name === 'attachments') continue;
const full = path.join(d, entry.name);
if (entry.isDirectory()) { walk(full); continue; }
const rel = path.relative(dir, full);
if (!before.has(rel) && fs.statSync(full).size <= 20 << 20) out.push(rel);
}
};
walk(dir);
return out.slice(0, 20);
}
async function runJob(jobId) {
const job = jobs.get(jobId);
if (!job || job.status !== 'queued') return;
job.status = 'running';
job.startedAt = new Date().toISOString();
const r = job.request;
const dir = path.join(WORK_DIR, jobId);
let result;
try {
fs.mkdirSync(path.join(dir, 'attachments'), { recursive: true });
// optional repo clone (§9.2)
if (r.context && r.context.repositoryUrl) {
const args = ['clone', '--depth', '1'];
if (r.context.branch) args.push('-b', r.context.branch);
args.push(r.context.repositoryUrl, path.join(dir, 'repo'));
await new Promise((resolve) => {
execFile('git', args, { timeout: 120000 }, (err, _o, stderr) => {
if (err) log('git clone failed', { jobId, err: String(stderr || err) });
resolve();
});
});
}
for (const a of r.attachments || []) {
try {
await download(a.url, path.join(dir, 'attachments', path.basename(a.name)));
} catch (e) { log('attachment download failed', { jobId, name: a.name, err: e.message }); }
}
fs.writeFileSync(path.join(dir, 'TASK.md'), buildTaskMD(r));
const before = new Set(['TASK.md']);
if (claudeAvailable()) {
result = await new Promise((resolve) => {
const child = execFile('claude',
['-p', fs.readFileSync(path.join(dir, 'TASK.md'), 'utf8'),
'--output-format', 'json', '--dangerously-skip-permissions'],
{ cwd: dir, timeout: 30 * 60 * 1000, maxBuffer: 32 << 20 },
(err, stdout, stderr) => {
if (job.cancel) { resolve({ status: 'failed', summary: 'job canceled', log: '' }); return; }
if (err) {
resolve({ status: 'failed', summary: `claude execution failed: ${err.message}`,
log: String(stderr || '').slice(-4000) });
return;
}
let summary = 'Claude Code completed the task.';
try {
const parsed = JSON.parse(stdout);
summary = parsed.result || parsed.summary || summary;
} catch (e) { summary = String(stdout).slice(0, 1000) || summary; }
resolve({ status: 'succeeded', summary: String(summary).slice(0, 4000),
log: String(stdout).slice(-4000) });
});
job.kill = () => child.kill('SIGTERM');
});
} else {
// offline placeholder result keeps the end-to-end flow demonstrable
const summary = `Simulated work performer result (Claude Code CLI not available in this container).\n` +
`Reviewed task "${r.title}" with ${(r.acceptanceCriteria || []).length} acceptance criteria.`;
fs.writeFileSync(path.join(dir, 'SUMMARY.md'),
`# Simulated result\n\n${summary}\n\nThis placeholder proves the §5.2 contract end to end.`);
result = { status: 'succeeded', summary, log: 'claude CLI unavailable; produced simulated SUMMARY.md' };
}
const artifacts = listProducedFiles(dir, before).map((rel) => ({
name: rel.replace(/\//g, '_'),
url: `${PUBLIC_BASE}/artifacts/${jobId}/${encodeURIComponent(rel)}`,
}));
result.artifacts = artifacts;
} catch (e) {
result = { status: 'failed', summary: `job crashed: ${e.message}`, log: '', artifacts: [] };
}
job.status = result.status;
job.finishedAt = new Date().toISOString();
const payload = {
jobId, taskId: r.taskId, status: result.status,
summary: result.summary, artifacts: result.artifacts || [], log: result.log || '',
};
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const code = await postCallback(r.callbackUrl, payload);
log('callback delivered', { jobId, code });
break;
} catch (e) {
log('callback failed', { jobId, attempt, err: e.message });
await new Promise((s) => setTimeout(s, attempt * 2000));
}
}
}
async function pump() {
if (running) return;
const next = queue.shift();
if (!next) return;
running = true;
try { await runJob(next); } finally {
running = false;
setImmediate(pump);
}
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (req.method === 'GET' && url.pathname === '/healthz') {
return json(res, 200, { status: 'ok' });
}
// artifact downloads are unauthenticated within the compose network
const artMatch = url.pathname.match(/^\/artifacts\/([\w]+)\/(.+)$/);
if (req.method === 'GET' && artMatch) {
const file = path.join(WORK_DIR, artMatch[1], decodeURIComponent(artMatch[2]));
if (!file.startsWith(path.join(WORK_DIR, artMatch[1]) + path.sep) || !fs.existsSync(file)) {
return errJson(res, 404, 'not_found', 'artifact not found');
}
res.writeHead(200, { 'Content-Type': 'application/octet-stream' });
return fs.createReadStream(file).pipe(res);
}
if (TOKEN && req.headers.authorization !== `Bearer ${TOKEN}`) {
return errJson(res, 401, 'unauthorized', 'missing or invalid bearer token');
}
if (req.method === 'POST' && url.pathname === '/v1/jobs') {
let body;
try { body = JSON.parse(await readBody(req)); }
catch (e) { return errJson(res, 400, 'bad_request', e.message); }
if (!body.taskId || !body.title || !body.callbackUrl) {
return errJson(res, 400, 'bad_request', 'taskId, title and callbackUrl are required');
}
const jobId = 'wp_' + crypto.randomBytes(8).toString('hex');
jobs.set(jobId, { status: 'queued', taskId: body.taskId, request: body,
startedAt: null, finishedAt: null, cancel: false });
queue.push(jobId);
setImmediate(pump);
log('job queued', { jobId, taskId: body.taskId });
return json(res, 202, { jobId, status: 'queued' });
}
const jobMatch = url.pathname.match(/^\/v1\/jobs\/(wp_[\w]+)$/);
if (jobMatch) {
const job = jobs.get(jobMatch[1]);
if (!job) return errJson(res, 404, 'not_found', 'job not found');
if (req.method === 'GET') {
return json(res, 200, { jobId: jobMatch[1], status: job.status,
startedAt: job.startedAt, finishedAt: job.finishedAt });
}
if (req.method === 'DELETE') { // best-effort cancel (§5.2)
job.cancel = true;
const idx = queue.indexOf(jobMatch[1]);
if (idx >= 0) { queue.splice(idx, 1); job.status = 'failed'; job.finishedAt = new Date().toISOString(); }
if (job.kill) try { job.kill(); } catch (e) { /* already gone */ }
return json(res, 200, { jobId: jobMatch[1], status: 'cancel_requested' });
}
}
errJson(res, 404, 'not_found', 'unknown endpoint');
});
server.listen(PORT, () => log('work-performer listening', { port: PORT, claude: claudeAvailable() }));
process.on('SIGTERM', () => server.close(() => process.exit(0)));
+565 -88
View File
@@ -1,136 +1,393 @@
/* Bounty Board hand-written CSS, no framework (§2.2).
Theming via CSS custom properties on <html data-theme>. */
/* Bounty Board "The Ledger".
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] {
--bg:#f3ead9; --surface:#faf5ea; --surface2:#efe5d0; --border:#d8cbb0;
--text:#2b2620; --muted:#6f6353; --accent:#8a5a2b; --accent-contrast:#fff;
--ok:#3c6e47; --warn:#a06a1f; --err:#9c3a2e; --radius:2px;
--bg:#ffffff; --surface:#fdfbf6; --surface2:#f2e9d8; --border:#a88452;
--text:#33230e; --muted:#7c6647; --accent:#7a4a14; --accent-contrast:#fff;
--ok:#2e6b3c; --warn:#955d0e; --err:#9c3a2e; --radius:2px;
}
:root[data-theme=dark] {
--bg:#191714; --surface:#221f1b; --surface2:#2b2722; --border:#3a342c;
--text:#ece5d8; --muted:#a59a87; --accent:#caa15e; --accent-contrast:#1a160f;
--bg:#171209; --surface:#221a0e; --surface2:#2e2414; --border:#7c5f33;
--text:#f3e8d2; --muted:#b59c74; --accent:#d9a548; --accent-contrast:#241606;
--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 ---- */
* { box-sizing: border-box; }
/* author display rules (flex etc.) must never defeat the hidden attribute */
[hidden] { display: none !important; }
html { font-size: 16px; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.5;
font-family: var(--font-body);
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:hover { text-decoration: underline; }
h1, h2, h3 { line-height: 1.25; margin: 0 0 16px; }
h1 { font-size: 1.5rem; }
h2 { font-size: 1.2rem; }
h3 { font-size: 1rem; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
a:hover { text-decoration: underline; text-underline-offset: 3px; }
h1, h2, h3 {
font-family: var(--font-display);
font-weight: 600;
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 {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* numerals everywhere read like a ledger */
table, .badge, input[type=number] { font-variant-numeric: tabular-nums; }
/* ---- 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; }
/* 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 {
display: flex; align-items: center; gap: 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 8px 16px;
display: flex; align-items: center; gap: 18px;
background-color: var(--surface2);
background-image: var(--dither);
background-blend-mode: normal;
border-bottom: 2px solid var(--border);
padding: 10px 20px;
position: relative;
}
.topnav .brand { font-weight: 700; color: var(--text); }
.topnav .links { display: flex; gap: 8px; flex: 1; flex-wrap: wrap; }
:root[data-theme=light] .topnav { background-image: none; background-color: var(--surface2); }
.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 {
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; }
/* ---- components ---- */
/* ---- cards: printed stock with hard Y2K shadows ---- */
.card {
background: var(--surface);
border: 1px solid var(--border);
border: var(--hairline);
border-radius: var(--radius);
box-shadow: var(--ink-shadow);
padding: 24px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.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 {
display: inline-flex; align-items: center; gap: 8px;
font: inherit; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
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;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface2);
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 {
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.ghost { background: transparent; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn.small { padding: 4px 8px; font-size: 0.875rem; }
.btn.danger { background: var(--err); color: #fff; border-color: color-mix(in srgb, var(--err) 65%, black); }
.btn.ghost { background: transparent; border-color: transparent; box-shadow: none; }
.btn.ghost:hover { border-color: var(--border); }
.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; }
.hint { color: var(--muted); font-size: 0.875rem; margin: 4px 0 0; }
/* ---- forms ---- */
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],
select, textarea {
input[type=search], input[type=date], input[type=file], select, textarea {
width: 100%;
font: inherit; color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border: var(--hairline);
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; }
.field { margin-bottom: 16px; }
input:focus, select:focus, textarea:focus {
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 > * { flex: 1; }
.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);
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; }
/* ---- badges: rubber stamps & banknote chips ---- */
.badge {
display: inline-block; font-size: 0.75rem; font-weight: 600;
padding: 2px 8px; border-radius: var(--radius);
background: var(--surface2); color: var(--muted);
display: inline-block;
font-family: var(--font-mono);
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);
}
.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 {
width: 32px; height: 32px; border-radius: var(--radius);
background: var(--accent); color: var(--accent-contrast);
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;
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); }
/* ---- tables: the ledger itself ---- */
table.list { width: 100%; border-collapse: collapse; }
table.list th, table.list td {
text-align: left; padding: 8px;
border-bottom: 1px solid var(--border);
text-align: left; padding: 9px 10px;
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); }
.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 input { flex: 1; }
/* toast notifications */
/* ---- toasts: telegram slips (above the chat bubble) ---- */
#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;
}
.toast {
background: var(--surface); border: 1px solid var(--border);
border-left: 3px solid var(--accent);
background: var(--surface);
border: var(--hairline);
border-left: 4px solid var(--accent);
border-radius: var(--radius);
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.ok { border-left-color: var(--ok); }
/* kanban */
.kanban { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.kanban-col {
background: var(--surface2); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px; min-height: 200px;
/* ---- masthead (login / register) ---- */
.masthead { text-align: center; margin-bottom: 26px; }
.masthead-kicker {
font-family: var(--font-mono);
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 {
position: absolute; top: -4px; right: -4px;
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);
}
.notif-panel {
position: absolute; right: 0; top: 36px; z-index: 50;
width: 320px; max-height: 420px; overflow: auto;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 12px;
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
position: absolute; right: 0; top: 38px; z-index: 50;
width: 330px; max-height: 420px; overflow: auto;
background: var(--surface);
border: var(--hairline);
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 li { padding: 8px 0; border-bottom: 1px solid var(--border); }
.notif-list a { color: var(--text); text-decoration: none; }
.notif-list li { padding: 9px 0; border-bottom: var(--hairline); }
.notif-list a { color: var(--text); }
.notif-list a:hover { text-decoration: none; opacity: 0.85; }
/* atomizing progress shimmer */
.shimmer {
position: relative;
overflow: hidden;
}
/* ---- atomizing shimmer ---- */
.shimmer { position: relative; overflow: hidden; }
.shimmer::after {
content: "";
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;
}
@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); }
.tabs { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
.tabs [aria-selected=true] { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); }
/* ---- tabs ---- */
.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 {
background: var(--surface); color: var(--text);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 24px; max-width: 90vw; max-height: 90vh; overflow: auto;
border: var(--hairline);
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 {
border: 1px solid var(--border); border-radius: var(--radius);
border: var(--hairline);
border-radius: var(--radius);
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) {
.row { flex-direction: column; gap: 0; }
.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') };
case 'youtrack':
return { permanentToken: val('c-yt-token') };
case 'wekan':
return { username: val('c-wekan-user'), password: val('c-wekan-pass') };
default:
return {};
}
+4
View File
@@ -149,6 +149,8 @@ function renderChildren(parent, kids, depth) {
</label>
<span>
<button class="btn small" data-act="edit">Edit</button>
${['imported', 'atomized'].includes(k.status)
? '<button class="btn small" data-act="subdivide" data-needs-atomizer>Subdivide</button>' : ''}
<button class="btn small" data-act="extend" data-needs-atomizer>Extend</button>
${k.status === 'atomized' ? '<button class="btn small primary" data-act="publish">Publish</button>' : ''}
<button class="btn small" data-act="archive">Archive</button>
@@ -183,6 +185,8 @@ function renderChildren(parent, kids, depth) {
});
}
row.querySelector('[data-act=edit]').addEventListener('click', () => openEdit(k, false));
const sd = row.querySelector('[data-act=subdivide]');
if (sd) sd.addEventListener('click', () => openSubdivide(k));
row.querySelector('[data-act=extend]').addEventListener('click', () => openExtend(k));
row.querySelector('[data-act=archive]').addEventListener('click', () => archive(k));
const pub = row.querySelector('[data-act=publish]');
+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="azure_devops">Azure DevOps</option>
<option value="youtrack">YouTrack</option>
<option value="wekan">WeKan</option>
<option value="demo">Demo (offline)</option>
</select>
</div>
@@ -58,6 +59,12 @@
<legend>YouTrack credentials</legend>
<div class="field"><label for="c-yt-token">Permanent token</label><input type="password" id="c-yt-token" autocomplete="off"></div>
</fieldset>
<fieldset id="creds-wekan" class="creds" hidden>
<legend>WeKan credentials</legend>
<p class="hint">Project key above = the WeKan board id; the connector imports cards assigned to each consultant's WeKan username.</p>
<div class="field"><label for="c-wekan-user">Username</label><input type="text" id="c-wekan-user" autocomplete="off"></div>
<div class="field"><label for="c-wekan-pass">Password</label><input type="password" id="c-wekan-pass" autocomplete="off"></div>
</fieldset>
<fieldset id="creds-demo" class="creds" hidden>
<legend>Demo</legend>
<p class="hint">No credentials required — tickets are fabricated locally.</p>
+1 -3
View File
@@ -2,7 +2,7 @@
<h1>Bounty Board</h1>
<div class="error-box" id="error" role="alert"></div>
<div class="row mt" id="board-filters">
<div class="toolbar mt" id="board-filters">
<div class="field">
<label for="f-q">Search</label>
<input type="search" id="f-q" placeholder="Search title or description… ( / )">
@@ -22,9 +22,7 @@
<option value="bounty">Highest bounty</option>
</select>
</div>
<div class="field" style="flex:0;align-self:flex-end">
<button class="btn" id="f-save" title="Save current filters as default">Save</button>
</div>
</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>
<div id="toasts" aria-live="polite"></div>
<script type="module" src="/static/js/nav.js"></script>
{{if .User}}<script type="module" src="/static/js/notifications.js"></script>{{end}}
{{if .User}}<script type="module" src="/static/js/notifications.js"></script>
<script type="module" src="/static/js/shortcuts.js"></script>
<script type="module" src="/static/js/chat-widget.js"></script>{{end}}
{{range .Scripts}}<script type="module" src="{{.}}"></script>
{{end}}
</body>
+6 -1
View File
@@ -1,4 +1,9 @@
{{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">
<h1>Log in</h1>
<div class="error-box" id="error" role="alert">{{.Error}}</div>
@@ -13,7 +18,7 @@
</div>
<div class="spread">
<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>
</form>
{{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">
<label for="p-theme">Theme</label>
<select id="p-theme">
<option value="light" {{if eq .User.Settings.Theme "light"}}selected{{end}}>Light (beige)</option>
<option value="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>
</select>
</div>
+5
View File
@@ -1,4 +1,9 @@
{{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">
<h1>Create account</h1>
<p class="hint">Self-registration creates a developer account. Consultant and admin roles are granted by an administrator.</p>
+17
View File
@@ -0,0 +1,17 @@
{{define "content"}}
<div class="card">
<h1>Set a new password</h1>
<div class="error-box" id="error" role="alert"></div>
<form id="reset-form">
<div class="field">
<label for="new">New password</label>
<input type="password" id="new" autocomplete="new-password" minlength="8" required>
</div>
<div class="field">
<label for="confirm">Confirm new password</label>
<input type="password" id="confirm" autocomplete="new-password" minlength="8" required>
</div>
<button class="btn primary" type="submit">Set password</button>
</form>
</div>
{{end}}